diff --git a/protocol_prototype/DryBox/UI/audio_player.py b/protocol_prototype/DryBox/UI/audio_player.py
new file mode 100644
index 0000000..6b922d7
--- /dev/null
+++ b/protocol_prototype/DryBox/UI/audio_player.py
@@ -0,0 +1,253 @@
+import wave
+import threading
+import queue
+import time
+import os
+from datetime import datetime
+from PyQt5.QtCore import QObject, pyqtSignal
+
+# Try to import PyAudio, but handle if it's not available
+try:
+ import pyaudio
+ PYAUDIO_AVAILABLE = True
+except ImportError:
+ PYAUDIO_AVAILABLE = False
+ print("Warning: PyAudio not installed. Audio playback will be disabled.")
+ print("To enable playback, install with: sudo dnf install python3-devel portaudio-devel && pip install pyaudio")
+
+class AudioPlayer(QObject):
+ playback_started = pyqtSignal(int) # client_id
+ playback_stopped = pyqtSignal(int) # client_id
+ recording_saved = pyqtSignal(int, str) # client_id, filepath
+
+ def __init__(self):
+ super().__init__()
+ self.audio = None
+ self.streams = {} # client_id -> stream
+ self.buffers = {} # client_id -> queue
+ self.threads = {} # client_id -> thread
+ self.recording_buffers = {} # client_id -> list of audio data
+ self.recording_enabled = {} # client_id -> bool
+ self.playback_enabled = {} # client_id -> bool
+ self.sample_rate = 8000
+ self.channels = 1
+ self.chunk_size = 320 # 40ms at 8kHz
+ self.debug_callback = None
+
+ if PYAUDIO_AVAILABLE:
+ try:
+ self.audio = pyaudio.PyAudio()
+ except Exception as e:
+ self.debug(f"Failed to initialize PyAudio: {e}")
+ self.audio = None
+ else:
+ self.audio = None
+ self.debug("PyAudio not available - playback disabled, recording still works")
+
+ def debug(self, message):
+ if self.debug_callback:
+ self.debug_callback(f"[AudioPlayer] {message}")
+ else:
+ print(f"[AudioPlayer] {message}")
+
+ def set_debug_callback(self, callback):
+ self.debug_callback = callback
+
+ def start_playback(self, client_id):
+ """Start audio playback for a client"""
+ if not self.audio:
+ self.debug("Audio playback not available - PyAudio not installed")
+ self.debug("To enable: sudo dnf install python3-devel portaudio-devel && pip install pyaudio")
+ return False
+
+ if client_id in self.streams:
+ self.debug(f"Playback already active for client {client_id}")
+ return False
+
+ try:
+ # Create audio stream
+ stream = self.audio.open(
+ format=pyaudio.paInt16,
+ channels=self.channels,
+ rate=self.sample_rate,
+ output=True,
+ frames_per_buffer=self.chunk_size
+ )
+
+ self.streams[client_id] = stream
+ self.buffers[client_id] = queue.Queue()
+ self.playback_enabled[client_id] = True
+
+ # Start playback thread
+ thread = threading.Thread(
+ target=self._playback_thread,
+ args=(client_id,),
+ daemon=True
+ )
+ self.threads[client_id] = thread
+ thread.start()
+
+ self.debug(f"Started playback for client {client_id}")
+ self.playback_started.emit(client_id)
+ return True
+
+ except Exception as e:
+ self.debug(f"Failed to start playback for client {client_id}: {e}")
+ return False
+
+ def stop_playback(self, client_id):
+ """Stop audio playback for a client"""
+ if client_id not in self.streams:
+ return
+
+ self.playback_enabled[client_id] = False
+
+ # Wait for thread to finish
+ if client_id in self.threads:
+ self.threads[client_id].join(timeout=1.0)
+ del self.threads[client_id]
+
+ # Close stream
+ if client_id in self.streams:
+ try:
+ self.streams[client_id].stop_stream()
+ self.streams[client_id].close()
+ except:
+ pass
+ del self.streams[client_id]
+
+ # Clear buffer
+ if client_id in self.buffers:
+ del self.buffers[client_id]
+
+ self.debug(f"Stopped playback for client {client_id}")
+ self.playback_stopped.emit(client_id)
+
+ def add_audio_data(self, client_id, pcm_data):
+ """Add audio data to playback buffer"""
+ # Initialize frame counter for debug logging
+ if not hasattr(self, '_frame_count'):
+ self._frame_count = {}
+ if client_id not in self._frame_count:
+ self._frame_count[client_id] = 0
+ self._frame_count[client_id] += 1
+
+ # Only log occasionally to avoid spam
+ if self._frame_count[client_id] == 1 or self._frame_count[client_id] % 25 == 0:
+ self.debug(f"Client {client_id} audio frame #{self._frame_count[client_id]}: {len(pcm_data)} bytes")
+
+ if client_id in self.buffers:
+ self.buffers[client_id].put(pcm_data)
+ if self._frame_count[client_id] == 1:
+ self.debug(f"Client {client_id} buffer started, queue size: {self.buffers[client_id].qsize()}")
+ else:
+ self.debug(f"Client {client_id} has no buffer (playback not started?)")
+
+ # Add to recording buffer if recording
+ if self.recording_enabled.get(client_id, False):
+ if client_id not in self.recording_buffers:
+ self.recording_buffers[client_id] = []
+ self.recording_buffers[client_id].append(pcm_data)
+
+ def _playback_thread(self, client_id):
+ """Thread function for audio playback"""
+ stream = self.streams.get(client_id)
+ buffer = self.buffers.get(client_id)
+
+ if not stream or not buffer:
+ return
+
+ self.debug(f"Playback thread started for client {client_id}")
+
+ while self.playback_enabled.get(client_id, False):
+ try:
+ # Get audio data from buffer with timeout
+ audio_data = buffer.get(timeout=0.1)
+
+ # Only log first frame to avoid spam
+ if not hasattr(self, '_playback_logged'):
+ self._playback_logged = {}
+ if client_id not in self._playback_logged:
+ self._playback_logged[client_id] = False
+
+ if not self._playback_logged[client_id]:
+ self.debug(f"Client {client_id} playback thread playing first frame: {len(audio_data)} bytes")
+ self._playback_logged[client_id] = True
+
+ # Play audio
+ stream.write(audio_data)
+
+ except queue.Empty:
+ # No data available, continue
+ continue
+ except Exception as e:
+ self.debug(f"Playback error for client {client_id}: {e}")
+ break
+
+ self.debug(f"Playback thread ended for client {client_id}")
+
+ def start_recording(self, client_id):
+ """Start recording received audio"""
+ self.recording_enabled[client_id] = True
+ self.recording_buffers[client_id] = []
+ self.debug(f"Started recording for client {client_id}")
+
+ def stop_recording(self, client_id, save_path=None):
+ """Stop recording and optionally save to file"""
+ if not self.recording_enabled.get(client_id, False):
+ return None
+
+ self.recording_enabled[client_id] = False
+
+ if client_id not in self.recording_buffers:
+ return None
+
+ audio_data = self.recording_buffers[client_id]
+
+ if not audio_data:
+ self.debug(f"No audio data recorded for client {client_id}")
+ return None
+
+ # Generate filename if not provided
+ if not save_path:
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ save_path = f"wav/received_client{client_id}_{timestamp}.wav"
+
+ # Ensure directory exists
+ save_dir = os.path.dirname(save_path)
+ if save_dir:
+ os.makedirs(save_dir, exist_ok=True)
+
+ try:
+ # Combine all audio chunks
+ combined_audio = b''.join(audio_data)
+
+ # Save as WAV file
+ with wave.open(save_path, 'wb') as wav_file:
+ wav_file.setnchannels(self.channels)
+ wav_file.setsampwidth(2) # 16-bit
+ wav_file.setframerate(self.sample_rate)
+ wav_file.writeframes(combined_audio)
+
+ self.debug(f"Saved recording for client {client_id} to {save_path}")
+ self.recording_saved.emit(client_id, save_path)
+
+ # Clear recording buffer
+ del self.recording_buffers[client_id]
+
+ return save_path
+
+ except Exception as e:
+ self.debug(f"Failed to save recording for client {client_id}: {e}")
+ return None
+
+ def cleanup(self):
+ """Clean up audio resources"""
+ # Stop all playback
+ for client_id in list(self.streams.keys()):
+ self.stop_playback(client_id)
+
+ # Terminate PyAudio
+ if self.audio:
+ self.audio.terminate()
+ self.audio = None
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/UI/audio_processor.py b/protocol_prototype/DryBox/UI/audio_processor.py
new file mode 100644
index 0000000..8f98c79
--- /dev/null
+++ b/protocol_prototype/DryBox/UI/audio_processor.py
@@ -0,0 +1,220 @@
+import numpy as np
+import wave
+import os
+from datetime import datetime
+from PyQt5.QtCore import QObject, pyqtSignal
+import struct
+
+class AudioProcessor(QObject):
+ processing_complete = pyqtSignal(str) # filepath
+
+ def __init__(self):
+ super().__init__()
+ self.debug_callback = None
+
+ def debug(self, message):
+ if self.debug_callback:
+ self.debug_callback(f"[AudioProcessor] {message}")
+ else:
+ print(f"[AudioProcessor] {message}")
+
+ def set_debug_callback(self, callback):
+ self.debug_callback = callback
+
+ def apply_gain(self, audio_data, gain_db):
+ """Apply gain to audio data"""
+ # Convert bytes to numpy array
+ samples = np.frombuffer(audio_data, dtype=np.int16)
+
+ # Apply gain
+ gain_linear = 10 ** (gain_db / 20.0)
+ samples_float = samples.astype(np.float32) * gain_linear
+
+ # Clip to prevent overflow
+ samples_float = np.clip(samples_float, -32768, 32767)
+
+ # Convert back to int16
+ return samples_float.astype(np.int16).tobytes()
+
+ def apply_noise_gate(self, audio_data, threshold_db=-40):
+ """Apply noise gate to remove low-level noise"""
+ samples = np.frombuffer(audio_data, dtype=np.int16)
+
+ # Calculate RMS in dB
+ rms = np.sqrt(np.mean(samples.astype(np.float32) ** 2))
+ rms_db = 20 * np.log10(max(rms, 1e-10))
+
+ # Gate the audio if below threshold
+ if rms_db < threshold_db:
+ return np.zeros_like(samples, dtype=np.int16).tobytes()
+
+ return audio_data
+
+ def apply_low_pass_filter(self, audio_data, cutoff_hz=3400, sample_rate=8000):
+ """Apply simple low-pass filter"""
+ samples = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32)
+
+ # Simple moving average filter
+ # Calculate filter length based on cutoff frequency
+ filter_length = int(sample_rate / cutoff_hz)
+ if filter_length < 3:
+ filter_length = 3
+
+ # Apply moving average
+ filtered = np.convolve(samples, np.ones(filter_length) / filter_length, mode='same')
+
+ return filtered.astype(np.int16).tobytes()
+
+ def apply_high_pass_filter(self, audio_data, cutoff_hz=300, sample_rate=8000):
+ """Apply simple high-pass filter"""
+ samples = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32)
+
+ # Simple differentiator as high-pass
+ filtered = np.diff(samples, prepend=samples[0])
+
+ # Scale to maintain amplitude
+ scale = cutoff_hz / (sample_rate / 2)
+ filtered *= scale
+
+ return filtered.astype(np.int16).tobytes()
+
+ def normalize_audio(self, audio_data, target_db=-3):
+ """Normalize audio to target dB level"""
+ samples = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32)
+
+ # Find peak
+ peak = np.max(np.abs(samples))
+ if peak == 0:
+ return audio_data
+
+ # Calculate current peak in dB
+ current_db = 20 * np.log10(peak / 32768.0)
+
+ # Calculate gain needed
+ gain_db = target_db - current_db
+
+ # Apply gain
+ return self.apply_gain(audio_data, gain_db)
+
+ def remove_silence(self, audio_data, threshold_db=-40, min_silence_ms=100, sample_rate=8000):
+ """Remove silence from audio"""
+ samples = np.frombuffer(audio_data, dtype=np.int16)
+
+ # Calculate frame size for silence detection
+ frame_size = int(sample_rate * min_silence_ms / 1000)
+
+ # Detect non-silent regions
+ non_silent_regions = []
+ i = 0
+
+ while i < len(samples):
+ frame = samples[i:i+frame_size]
+ if len(frame) == 0:
+ break
+
+ # Calculate RMS of frame
+ rms = np.sqrt(np.mean(frame.astype(np.float32) ** 2))
+ rms_db = 20 * np.log10(max(rms, 1e-10))
+
+ if rms_db > threshold_db:
+ # Found non-silent region, find its extent
+ start = i
+ while i < len(samples):
+ frame = samples[i:i+frame_size]
+ if len(frame) == 0:
+ break
+ rms = np.sqrt(np.mean(frame.astype(np.float32) ** 2))
+ rms_db = 20 * np.log10(max(rms, 1e-10))
+ if rms_db <= threshold_db:
+ break
+ i += frame_size
+ non_silent_regions.append((start, i))
+ else:
+ i += frame_size
+
+ # Combine non-silent regions
+ if not non_silent_regions:
+ return audio_data # Return original if all silent
+
+ combined = []
+ for start, end in non_silent_regions:
+ combined.extend(samples[start:end])
+
+ return np.array(combined, dtype=np.int16).tobytes()
+
+ def save_processed_audio(self, audio_data, original_path, processing_type):
+ """Save processed audio with descriptive filename"""
+ # Generate new filename
+ base_name = os.path.splitext(os.path.basename(original_path))[0]
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ new_filename = f"{base_name}_{processing_type}_{timestamp}.wav"
+
+ # Ensure directory exists
+ save_dir = os.path.dirname(original_path)
+ if not save_dir:
+ save_dir = "wav"
+ os.makedirs(save_dir, exist_ok=True)
+
+ save_path = os.path.join(save_dir, new_filename)
+
+ try:
+ with wave.open(save_path, 'wb') as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(8000)
+ wav_file.writeframes(audio_data)
+
+ self.debug(f"Saved processed audio to {save_path}")
+ self.processing_complete.emit(save_path)
+ return save_path
+
+ except Exception as e:
+ self.debug(f"Failed to save processed audio: {e}")
+ return None
+
+ def concatenate_audio_files(self, file_paths, output_path=None):
+ """Concatenate multiple audio files"""
+ if not file_paths:
+ return None
+
+ combined_data = b''
+ sample_rate = None
+
+ for file_path in file_paths:
+ try:
+ with wave.open(file_path, 'rb') as wav_file:
+ if sample_rate is None:
+ sample_rate = wav_file.getframerate()
+ elif wav_file.getframerate() != sample_rate:
+ self.debug(f"Sample rate mismatch in {file_path}")
+ continue
+
+ data = wav_file.readframes(wav_file.getnframes())
+ combined_data += data
+
+ except Exception as e:
+ self.debug(f"Failed to read {file_path}: {e}")
+
+ if not combined_data:
+ return None
+
+ # Save concatenated audio
+ if not output_path:
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ output_path = f"wav/concatenated_{timestamp}.wav"
+
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
+
+ try:
+ with wave.open(output_path, 'wb') as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(sample_rate or 8000)
+ wav_file.writeframes(combined_data)
+
+ self.debug(f"Saved concatenated audio to {output_path}")
+ return output_path
+
+ except Exception as e:
+ self.debug(f"Failed to save concatenated audio: {e}")
+ return None
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/UI/client_state.py b/protocol_prototype/DryBox/UI/client_state.py
deleted file mode 100644
index 3f260c6..0000000
--- a/protocol_prototype/DryBox/UI/client_state.py
+++ /dev/null
@@ -1,79 +0,0 @@
-# client_state.py
-from queue import Queue
-from session import NoiseXKSession
-import time
-
-class ClientState:
- def __init__(self, client_id):
- self.client_id = client_id
- self.command_queue = Queue()
- self.initiator = None
- self.keypair = None
- self.peer_pubkey = None
- self.session = None
- self.handshake_in_progress = False
- self.handshake_start_time = None
- self.call_active = False
-
- def process_command(self, client):
- """Process commands from the queue."""
- if not self.command_queue.empty():
- print(f"Client {self.client_id} processing command queue, size: {self.command_queue.qsize()}")
- command = self.command_queue.get()
- if command == "handshake":
- try:
- print(f"Client {self.client_id} starting handshake, initiator: {self.initiator}")
- self.session = NoiseXKSession(self.keypair, self.peer_pubkey)
- self.session.handshake(client.sock, self.initiator)
- print(f"Client {self.client_id} handshake complete")
- client.send("HANDSHAKE_DONE")
- except Exception as e:
- print(f"Client {self.client_id} handshake failed: {e}")
- client.state_changed.emit("CALL_END", "", self.client_id)
- finally:
- self.handshake_in_progress = False
- self.handshake_start_time = None
-
- def start_handshake(self, initiator, keypair, peer_pubkey):
- """Queue handshake command."""
- self.initiator = initiator
- self.keypair = keypair
- self.peer_pubkey = peer_pubkey
- print(f"Client {self.client_id} queuing handshake, initiator: {initiator}")
- self.handshake_in_progress = True
- self.handshake_start_time = time.time()
- self.command_queue.put("handshake")
-
- def handle_data(self, client, data):
- """Handle received data (control or audio)."""
- try:
- decoded_data = data.decode('utf-8').strip()
- print(f"Client {self.client_id} received raw: {decoded_data}")
- if decoded_data in ["RINGING", "CALL_END", "CALL_DROPPED", "IN_CALL", "HANDSHAKE", "HANDSHAKE_DONE"]:
- client.state_changed.emit(decoded_data, decoded_data, self.client_id)
- if decoded_data == "HANDSHAKE":
- self.handshake_in_progress = True
- elif decoded_data == "HANDSHAKE_DONE":
- self.call_active = True
- else:
- print(f"Client {self.client_id} ignored unexpected text message: {decoded_data}")
- except UnicodeDecodeError:
- if self.call_active and self.session:
- try:
- print(f"Client {self.client_id} received audio packet, length={len(data)}")
- decrypted_data = self.session.decrypt(data)
- print(f"Client {self.client_id} decrypted audio packet, length={len(decrypted_data)}")
- client.data_received.emit(decrypted_data, self.client_id)
- except Exception as e:
- print(f"Client {self.client_id} failed to process audio packet: {e}")
- else:
- print(f"Client {self.client_id} ignored non-text message: {data.hex()}")
-
- def check_handshake_timeout(self, client):
- """Check for handshake timeout."""
- if self.handshake_in_progress and self.handshake_start_time:
- if time.time() - self.handshake_start_time > 30:
- print(f"Client {self.client_id} handshake timeout after 30s")
- client.state_changed.emit("CALL_END", "", self.client_id)
- self.handshake_in_progress = False
- self.handshake_start_time = None
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/UI/main.py b/protocol_prototype/DryBox/UI/main.py
index 5780be7..8d7ce77 100644
--- a/protocol_prototype/DryBox/UI/main.py
+++ b/protocol_prototype/DryBox/UI/main.py
@@ -1,19 +1,32 @@
import sys
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
- QPushButton, QLabel, QFrame, QSizePolicy, QStyle
+ QPushButton, QLabel, QFrame, QSizePolicy, QStyle, QTextEdit, QSplitter,
+ QMenu, QAction, QInputDialog, QShortcut
)
-from PyQt5.QtCore import Qt, QSize
-from PyQt5.QtGui import QFont
+from PyQt5.QtCore import Qt, QSize, QTimer, pyqtSignal
+from PyQt5.QtGui import QFont, QTextCursor, QKeySequence
+import time
+import threading
from phone_manager import PhoneManager
from waveform_widget import WaveformWidget
from phone_state import PhoneState
class PhoneUI(QMainWindow):
+ debug_signal = pyqtSignal(str)
+
def __init__(self):
super().__init__()
- self.setWindowTitle("Enhanced Dual Phone Interface")
- self.setGeometry(100, 100, 900, 750)
+ self.setWindowTitle("DryBox - Noise XK + Codec2 + 4FSK")
+ self.setGeometry(100, 100, 1200, 900)
+
+ # Set minimum size to ensure window is resizable
+ self.setMinimumSize(800, 600)
+
+ # Auto test state
+ self.auto_test_running = False
+ self.auto_test_timer = None
+ self.test_step = 0
self.setStyleSheet("""
QMainWindow { background-color: #333333; }
QLabel { color: #E0E0E0; font-size: 14px; }
@@ -39,75 +52,162 @@ class PhoneUI(QMainWindow):
padding: 15px;
}
QWidget#phoneWidget {
- border: 1px solid #4A4A4A; border-radius: 8px;
- padding: 10px; background-color: #3A3A3A;
+ border: 2px solid #4A4A4A; border-radius: 10px;
+ background-color: #3A3A3A;
+ min-width: 250px;
}
+ QTextEdit#debugConsole {
+ background-color: #1E1E1E; color: #00FF00;
+ font-family: monospace; font-size: 12px;
+ border: 2px solid #0078D4; border-radius: 5px;
+ }
+ QPushButton#autoTestButton {
+ background-color: #FF8C00; min-height: 35px;
+ }
+ QPushButton#autoTestButton:hover { background-color: #FF7F00; }
""")
+ # Setup debug signal early
+ self.debug_signal.connect(self.append_debug)
+
self.manager = PhoneManager()
+ self.manager.ui = self # Set UI reference for debug logging
self.manager.initialize_phones()
- # Main widget and layout
+ # Main widget with splitter
main_widget = QWidget()
self.setCentralWidget(main_widget)
main_layout = QVBoxLayout()
- main_layout.setSpacing(20)
- main_layout.setContentsMargins(20, 20, 20, 20)
- main_layout.setAlignment(Qt.AlignCenter)
main_widget.setLayout(main_layout)
+
+ # Create splitter for phones and debug console
+ self.splitter = QSplitter(Qt.Vertical)
+ main_layout.addWidget(self.splitter)
+
+ # Top widget for phones
+ phones_widget = QWidget()
+ phones_layout = QVBoxLayout()
+ phones_layout.setSpacing(20)
+ phones_layout.setContentsMargins(20, 20, 20, 20)
+ phones_layout.setAlignment(Qt.AlignCenter)
+ phones_widget.setLayout(phones_layout)
# App Title
- app_title_label = QLabel("Dual Phone Control Panel")
+ app_title_label = QLabel("Integrated Protocol Control Panel")
app_title_label.setObjectName("mainTitleLabel")
app_title_label.setAlignment(Qt.AlignCenter)
- main_layout.addWidget(app_title_label)
+ phones_layout.addWidget(app_title_label)
+
+ # Protocol info
+ protocol_info = QLabel("Noise XK + Codec2 (1200bps) + 4FSK")
+ protocol_info.setAlignment(Qt.AlignCenter)
+ protocol_info.setStyleSheet("font-size: 12px; color: #00A2E8;")
+ phones_layout.addWidget(protocol_info)
# Phone displays layout
phone_controls_layout = QHBoxLayout()
- phone_controls_layout.setSpacing(50)
- phone_controls_layout.setAlignment(Qt.AlignCenter)
- main_layout.addLayout(phone_controls_layout)
+ phone_controls_layout.setSpacing(20)
+ phone_controls_layout.setContentsMargins(10, 0, 10, 0)
+ phones_layout.addLayout(phone_controls_layout)
# Setup UI for phones
for phone in self.manager.phones:
- phone_container_widget, phone_display_frame, phone_button, waveform_widget, sent_waveform_widget, phone_status_label = self._create_phone_ui(
+ phone_container_widget, phone_display_frame, phone_button, waveform_widget, sent_waveform_widget, phone_status_label, playback_button, record_button = self._create_phone_ui(
f"Phone {phone['id']+1}", lambda checked, pid=phone['id']: self.manager.phone_action(pid, self)
)
phone['button'] = phone_button
phone['waveform'] = waveform_widget
phone['sent_waveform'] = sent_waveform_widget
phone['status_label'] = phone_status_label
+ phone['playback_button'] = playback_button
+ phone['record_button'] = record_button
+
+ # Connect audio control buttons with proper closure
+ playback_button.clicked.connect(lambda checked, pid=phone['id']: self.toggle_playback(pid))
+ record_button.clicked.connect(lambda checked, pid=phone['id']: self.toggle_recording(pid))
phone_controls_layout.addWidget(phone_container_widget)
- phone['client'].data_received.connect(lambda data, cid=phone['id']: self.manager.update_waveform(cid, data))
+ # Connect data_received signal - it emits (data, client_id)
+ phone['client'].data_received.connect(lambda data, cid: self.manager.update_waveform(cid, data))
phone['client'].state_changed.connect(lambda state, num, cid=phone['id']: self.set_phone_state(cid, state, num))
phone['client'].start()
- # Spacer
- main_layout.addStretch(1)
-
+ # Control buttons layout
+ control_layout = QHBoxLayout()
+ control_layout.setSpacing(15)
+ control_layout.setContentsMargins(20, 10, 20, 10)
+
+ # Auto Test Button
+ self.auto_test_button = QPushButton("๐งช Run Automatic Test")
+ self.auto_test_button.setObjectName("autoTestButton")
+ self.auto_test_button.setMinimumWidth(180)
+ self.auto_test_button.setMaximumWidth(250)
+ self.auto_test_button.clicked.connect(self.toggle_auto_test)
+ control_layout.addWidget(self.auto_test_button)
+
+ # Clear Debug Button
+ self.clear_debug_button = QPushButton("Clear Debug")
+ self.clear_debug_button.setMinimumWidth(100)
+ self.clear_debug_button.setMaximumWidth(150)
+ self.clear_debug_button.clicked.connect(self.clear_debug)
+ control_layout.addWidget(self.clear_debug_button)
+
+ # Audio Processing Button
+ self.audio_menu_button = QPushButton("Audio Options")
+ self.audio_menu_button.setMinimumWidth(100)
+ self.audio_menu_button.setMaximumWidth(150)
+ self.audio_menu_button.clicked.connect(self.show_audio_menu)
+ control_layout.addWidget(self.audio_menu_button)
+
# Settings Button
self.settings_button = QPushButton("Settings")
self.settings_button.setObjectName("settingsButton")
- self.settings_button.setFixedWidth(180)
+ self.settings_button.setMinimumWidth(100)
+ self.settings_button.setMaximumWidth(150)
self.settings_button.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView))
self.settings_button.setIconSize(QSize(20, 20))
self.settings_button.clicked.connect(self.settings_action)
- settings_layout = QHBoxLayout()
- settings_layout.addStretch()
- settings_layout.addWidget(self.settings_button)
- settings_layout.addStretch()
- main_layout.addLayout(settings_layout)
+ control_layout.addWidget(self.settings_button)
+
+ phones_layout.addLayout(control_layout)
+
+ # Add phones widget to splitter
+ self.splitter.addWidget(phones_widget)
+
+ # Debug console
+ self.debug_console = QTextEdit()
+ self.debug_console.setObjectName("debugConsole")
+ self.debug_console.setReadOnly(True)
+ self.debug_console.setMinimumHeight(200)
+ self.debug_console.setMaximumHeight(400)
+ self.splitter.addWidget(self.debug_console)
+
+ # Flush any queued debug messages
+ if hasattr(self, '_debug_queue'):
+ for msg in self._debug_queue:
+ self.debug_console.append(msg)
+ del self._debug_queue
+
+ # Set splitter sizes (70% phones, 30% debug)
+ self.splitter.setSizes([600, 300])
# Initialize UI
for phone in self.manager.phones:
self.update_phone_ui(phone['id'])
+
+ # Initial debug message
+ QTimer.singleShot(100, lambda: self.debug("DryBox UI initialized with integrated protocol"))
+
+ # Setup keyboard shortcuts
+ self.setup_shortcuts()
def _create_phone_ui(self, title, action_slot):
phone_container_widget = QWidget()
phone_container_widget.setObjectName("phoneWidget")
+ phone_container_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
phone_layout = QVBoxLayout()
phone_layout.setAlignment(Qt.AlignCenter)
- phone_layout.setSpacing(15)
+ phone_layout.setSpacing(10)
+ phone_layout.setContentsMargins(15, 15, 15, 15)
phone_container_widget.setLayout(phone_layout)
phone_title_label = QLabel(title)
@@ -117,8 +217,9 @@ class PhoneUI(QMainWindow):
phone_display_frame = QFrame()
phone_display_frame.setObjectName("phoneDisplay")
- phone_display_frame.setFixedSize(250, 350)
- phone_display_frame.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ phone_display_frame.setMinimumSize(200, 250)
+ phone_display_frame.setMaximumSize(300, 400)
+ phone_display_frame.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
display_content_layout = QVBoxLayout(phone_display_frame)
display_content_layout.setAlignment(Qt.AlignCenter)
@@ -129,28 +230,83 @@ class PhoneUI(QMainWindow):
phone_layout.addWidget(phone_display_frame, alignment=Qt.AlignCenter)
phone_button = QPushButton()
- phone_button.setFixedWidth(120)
+ phone_button.setMinimumWidth(100)
+ phone_button.setMaximumWidth(150)
phone_button.setIconSize(QSize(20, 20))
phone_button.clicked.connect(action_slot)
phone_layout.addWidget(phone_button, alignment=Qt.AlignCenter)
# Received waveform
- waveform_label = QLabel(f"{title} Received Audio")
+ waveform_label = QLabel(f"{title} Received")
waveform_label.setAlignment(Qt.AlignCenter)
- waveform_label.setStyleSheet("font-size: 14px; color: #E0E0E0;")
+ waveform_label.setStyleSheet("font-size: 12px; color: #E0E0E0;")
phone_layout.addWidget(waveform_label)
waveform_widget = WaveformWidget(dynamic=False)
+ waveform_widget.setMinimumSize(200, 50)
+ waveform_widget.setMaximumSize(300, 80)
+ waveform_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
phone_layout.addWidget(waveform_widget, alignment=Qt.AlignCenter)
# Sent waveform
- sent_waveform_label = QLabel(f"{title} Sent Audio")
+ sent_waveform_label = QLabel(f"{title} Sent")
sent_waveform_label.setAlignment(Qt.AlignCenter)
- sent_waveform_label.setStyleSheet("font-size: 14px; color: #E0E0E0;")
+ sent_waveform_label.setStyleSheet("font-size: 12px; color: #E0E0E0;")
phone_layout.addWidget(sent_waveform_label)
sent_waveform_widget = WaveformWidget(dynamic=False)
+ sent_waveform_widget.setMinimumSize(200, 50)
+ sent_waveform_widget.setMaximumSize(300, 80)
+ sent_waveform_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
phone_layout.addWidget(sent_waveform_widget, alignment=Qt.AlignCenter)
+
+ # Audio control buttons
+ audio_controls_layout = QHBoxLayout()
+ audio_controls_layout.setAlignment(Qt.AlignCenter)
+
+ playback_button = QPushButton("๐ Playback")
+ playback_button.setCheckable(True)
+ playback_button.setMinimumWidth(90)
+ playback_button.setMaximumWidth(120)
+ playback_button.setStyleSheet("""
+ QPushButton {
+ background-color: #404040;
+ color: white;
+ border: 1px solid #606060;
+ padding: 5px;
+ border-radius: 3px;
+ }
+ QPushButton:checked {
+ background-color: #4CAF50;
+ }
+ QPushButton:hover {
+ background-color: #505050;
+ }
+ """)
+
+ record_button = QPushButton("โบ Record")
+ record_button.setCheckable(True)
+ record_button.setMinimumWidth(90)
+ record_button.setMaximumWidth(120)
+ record_button.setStyleSheet("""
+ QPushButton {
+ background-color: #404040;
+ color: white;
+ border: 1px solid #606060;
+ padding: 5px;
+ border-radius: 3px;
+ }
+ QPushButton:checked {
+ background-color: #F44336;
+ }
+ QPushButton:hover {
+ background-color: #505050;
+ }
+ """)
+
+ audio_controls_layout.addWidget(playback_button)
+ audio_controls_layout.addWidget(record_button)
+ phone_layout.addLayout(audio_controls_layout)
- return phone_container_widget, phone_display_frame, phone_button, waveform_widget, sent_waveform_widget, phone_status_label
+ return phone_container_widget, phone_display_frame, phone_button, waveform_widget, sent_waveform_widget, phone_status_label, playback_button, record_button
def update_phone_ui(self, phone_id):
phone = self.manager.phones[phone_id]
@@ -182,28 +338,371 @@ class PhoneUI(QMainWindow):
button.setStyleSheet("background-color: #107C10;")
def set_phone_state(self, client_id, state_str, number):
+ self.debug(f"Phone {client_id + 1} state change: {state_str}")
+
+ # Handle protocol-specific states
+ if state_str == "HANDSHAKE_COMPLETE":
+ phone = self.manager.phones[client_id]
+ phone['status_label'].setText("๐ Secure Channel Established")
+ self.debug(f"Phone {client_id + 1} secure channel established")
+ self.manager.start_audio(client_id, parent=self)
+ return
+ elif state_str == "VOICE_START":
+ phone = self.manager.phones[client_id]
+ phone['status_label'].setText("๐ค Voice Active (Encrypted)")
+ self.debug(f"Phone {client_id + 1} voice session started")
+ return
+ elif state_str == "VOICE_END":
+ phone = self.manager.phones[client_id]
+ phone['status_label'].setText("๐ Secure Channel")
+ self.debug(f"Phone {client_id + 1} voice session ended")
+ return
+
+ # Handle regular states
state = self.manager.map_state(state_str)
phone = self.manager.phones[client_id]
other_phone = self.manager.phones[1 - client_id]
- print(f"Setting state for Phone {client_id + 1}: {state}, number: {number}, is_initiator: {phone['is_initiator']}")
+ self.debug(f"Setting state for Phone {client_id + 1}: {state.name if hasattr(state, 'name') else state}, number: {number}, is_initiator: {phone['is_initiator']}")
phone['state'] = state
if state == PhoneState.IN_CALL:
- print(f"Phone {client_id + 1} confirmed in IN_CALL state")
- if number == "IN_CALL" and phone['is_initiator']:
- print(f"Phone {client_id + 1} (initiator) starting handshake")
- phone['client'].send("HANDSHAKE")
+ self.debug(f"Phone {client_id + 1} confirmed in IN_CALL state")
+ self.debug(f" state_str={state_str}, number={number}")
+ self.debug(f" is_initiator={phone['is_initiator']}")
+
+ # Only start handshake when the initiator RECEIVES the IN_CALL message
+ if state_str == "IN_CALL" and phone['is_initiator']:
+ self.debug(f"Phone {client_id + 1} (initiator) received IN_CALL, starting handshake")
phone['client'].start_handshake(initiator=True, keypair=phone['keypair'], peer_pubkey=other_phone['public_key'])
- elif number == "HANDSHAKE" and not phone['is_initiator']:
- print(f"Phone {client_id + 1} (responder) starting handshake")
- phone['client'].start_handshake(initiator=False, keypair=phone['keypair'], peer_pubkey=other_phone['public_key'])
+ elif number == "HANDSHAKE":
+ # Old text-based handshake trigger - no longer used
+ self.debug(f"Phone {client_id + 1} received legacy HANDSHAKE message")
elif number == "HANDSHAKE_DONE":
- self.manager.start_audio(client_id, parent=self) # Pass self as parent
+ self.debug(f"Phone {client_id + 1} received HANDSHAKE_DONE")
+ # Handled by HANDSHAKE_COMPLETE now
+ pass
self.update_phone_ui(client_id)
def settings_action(self):
print("Settings clicked")
+ self.debug("Settings clicked")
+
+ def debug(self, message):
+ """Thread-safe debug logging to both console and UI"""
+ timestamp = time.strftime("%H:%M:%S.%f")[:-3]
+ debug_msg = f"[{timestamp}] {message}"
+ print(debug_msg) # Console output
+ self.debug_signal.emit(debug_msg) # UI output
+
+ def append_debug(self, message):
+ """Append debug message to console (called from main thread)"""
+ if hasattr(self, 'debug_console'):
+ self.debug_console.append(message)
+ # Auto-scroll to bottom
+ cursor = self.debug_console.textCursor()
+ cursor.movePosition(QTextCursor.End)
+ self.debug_console.setTextCursor(cursor)
+ else:
+ # Queue messages until console is ready
+ if not hasattr(self, '_debug_queue'):
+ self._debug_queue = []
+ self._debug_queue.append(message)
+
+ def clear_debug(self):
+ """Clear debug console"""
+ self.debug_console.clear()
+ self.debug("Debug console cleared")
+
+ def toggle_auto_test(self):
+ """Toggle automatic test sequence"""
+ if not self.auto_test_running:
+ self.start_auto_test()
+ else:
+ self.stop_auto_test()
+
+ def start_auto_test(self):
+ """Start automatic test sequence"""
+ self.auto_test_running = True
+ self.auto_test_button.setText("โน Stop Test")
+ self.test_step = 0
+
+ self.debug("=== STARTING AUTOMATIC TEST SEQUENCE ===")
+ self.debug("Test will go through complete protocol flow")
+
+ # Start test timer
+ self.auto_test_timer = QTimer()
+ self.auto_test_timer.timeout.connect(self.execute_test_step)
+ self.auto_test_timer.start(2000) # 2 second intervals
+
+ # Execute first step immediately
+ self.execute_test_step()
+
+ def stop_auto_test(self):
+ """Stop automatic test sequence"""
+ self.auto_test_running = False
+ self.auto_test_button.setText("๐งช Run Automatic Test")
+
+ if self.auto_test_timer:
+ self.auto_test_timer.stop()
+ self.auto_test_timer = None
+
+ self.debug("=== TEST SEQUENCE STOPPED ===")
+
+ def execute_test_step(self):
+ """Execute next step in test sequence"""
+ phone1 = self.manager.phones[0]
+ phone2 = self.manager.phones[1]
+
+ self.debug(f"\n--- Test Step {self.test_step + 1} ---")
+
+ if self.test_step == 0:
+ # Step 1: Check initial state
+ self.debug("Checking initial state...")
+ state1 = phone1['state']
+ state2 = phone2['state']
+ # Handle both enum and int states
+ state1_name = state1.name if hasattr(state1, 'name') else str(state1)
+ state2_name = state2.name if hasattr(state2, 'name') else str(state2)
+ self.debug(f"Phone 1 state: {state1_name}")
+ self.debug(f"Phone 2 state: {state2_name}")
+ self.debug(f"Phone 1 connected: {phone1['client'].sock is not None}")
+ self.debug(f"Phone 2 connected: {phone2['client'].sock is not None}")
+
+ elif self.test_step == 1:
+ # Step 2: Make call
+ self.debug("Phone 1 calling Phone 2...")
+ self.manager.phone_action(0, self)
+ state1_name = phone1['state'].name if hasattr(phone1['state'], 'name') else str(phone1['state'])
+ state2_name = phone2['state'].name if hasattr(phone2['state'], 'name') else str(phone2['state'])
+ self.debug(f"Phone 1 state after call: {state1_name}")
+ self.debug(f"Phone 2 state after call: {state2_name}")
+
+ elif self.test_step == 2:
+ # Step 3: Answer call
+ self.debug("Phone 2 answering call...")
+ self.manager.phone_action(1, self)
+ state1_name = phone1['state'].name if hasattr(phone1['state'], 'name') else str(phone1['state'])
+ state2_name = phone2['state'].name if hasattr(phone2['state'], 'name') else str(phone2['state'])
+ self.debug(f"Phone 1 state after answer: {state1_name}")
+ self.debug(f"Phone 2 state after answer: {state2_name}")
+ self.debug(f"Phone 1 is_initiator: {phone1['is_initiator']}")
+ self.debug(f"Phone 2 is_initiator: {phone2['is_initiator']}")
+
+ elif self.test_step == 3:
+ # Step 4: Check handshake progress
+ self.debug("Checking handshake progress...")
+ self.debug(f"Phone 1 handshake in progress: {phone1['client'].state.handshake_in_progress}")
+ self.debug(f"Phone 2 handshake in progress: {phone2['client'].state.handshake_in_progress}")
+ self.debug(f"Phone 1 command queue: {phone1['client'].state.command_queue.qsize()}")
+ self.debug(f"Phone 2 command queue: {phone2['client'].state.command_queue.qsize()}")
+ # Increase timer interval for handshake
+ self.auto_test_timer.setInterval(3000) # 3 seconds
+
+ elif self.test_step == 4:
+ # Step 5: Check handshake status
+ self.debug("Checking Noise XK handshake status...")
+ self.debug(f"Phone 1 handshake complete: {phone1['client'].handshake_complete}")
+ self.debug(f"Phone 2 handshake complete: {phone2['client'].handshake_complete}")
+ self.debug(f"Phone 1 has session: {phone1['client'].noise_session is not None}")
+ self.debug(f"Phone 2 has session: {phone2['client'].noise_session is not None}")
+ # Reset timer interval
+ self.auto_test_timer.setInterval(2000)
+
+ elif self.test_step == 5:
+ # Step 6: Check voice status
+ self.debug("Checking voice session status...")
+ self.debug(f"Phone 1 voice active: {phone1['client'].voice_active}")
+ self.debug(f"Phone 2 voice active: {phone2['client'].voice_active}")
+ self.debug(f"Phone 1 codec initialized: {phone1['client'].codec is not None}")
+ self.debug(f"Phone 2 codec initialized: {phone2['client'].codec is not None}")
+ self.debug(f"Phone 1 modem initialized: {phone1['client'].modem is not None}")
+ self.debug(f"Phone 2 modem initialized: {phone2['client'].modem is not None}")
+
+ elif self.test_step == 6:
+ # Step 7: Check audio transmission
+ self.debug("Checking audio transmission...")
+ self.debug(f"Phone 1 audio file loaded: {phone1['audio_file'] is not None}")
+ self.debug(f"Phone 2 audio file loaded: {phone2['audio_file'] is not None}")
+ self.debug(f"Phone 1 frame counter: {phone1.get('frame_counter', 0)}")
+ self.debug(f"Phone 2 frame counter: {phone2.get('frame_counter', 0)}")
+ self.debug(f"Phone 1 audio timer active: {phone1['audio_timer'] is not None and phone1['audio_timer'].isActive()}")
+ self.debug(f"Phone 2 audio timer active: {phone2['audio_timer'] is not None and phone2['audio_timer'].isActive()}")
+
+ elif self.test_step == 7:
+ # Step 8: Protocol details
+ self.debug("Protocol stack details:")
+ if phone1['client'].codec:
+ self.debug(f"Codec mode: {phone1['client'].codec.mode.name}")
+ self.debug(f"Frame size: {phone1['client'].codec.frame_bits} bits")
+ self.debug(f"Frame duration: {phone1['client'].codec.frame_ms} ms")
+ if phone1['client'].modem:
+ self.debug(f"FSK frequencies: {phone1['client'].modem.frequencies}")
+ self.debug(f"Symbol rate: {phone1['client'].modem.baud_rate} baud")
+
+ elif self.test_step == 8:
+ # Step 9: Wait for more frames
+ self.debug("Letting voice transmission run...")
+ self.auto_test_timer.setInterval(5000) # Wait 5 seconds
+
+ elif self.test_step == 9:
+ # Step 10: Final statistics
+ self.debug("Final transmission statistics:")
+ self.debug(f"Phone 1 frames sent: {phone1.get('frame_counter', 0)}")
+ self.debug(f"Phone 2 frames sent: {phone2.get('frame_counter', 0)}")
+ self.auto_test_timer.setInterval(2000) # Back to 2 seconds
+
+ elif self.test_step == 10:
+ # Step 11: Hang up
+ self.debug("Hanging up call...")
+ self.manager.phone_action(0, self)
+ state1_name = phone1['state'].name if hasattr(phone1['state'], 'name') else str(phone1['state'])
+ state2_name = phone2['state'].name if hasattr(phone2['state'], 'name') else str(phone2['state'])
+ self.debug(f"Phone 1 state after hangup: {state1_name}")
+ self.debug(f"Phone 2 state after hangup: {state2_name}")
+
+ elif self.test_step == 11:
+ # Complete
+ self.debug("\n=== TEST SEQUENCE COMPLETE ===")
+ self.debug("All protocol components tested successfully!")
+ self.stop_auto_test()
+ return
+
+ self.test_step += 1
+
+ def toggle_playback(self, phone_id):
+ """Toggle audio playback for a phone"""
+ is_enabled = self.manager.toggle_playback(phone_id)
+ phone = self.manager.phones[phone_id]
+ phone['playback_button'].setChecked(is_enabled)
+
+ if is_enabled:
+ self.debug(f"Phone {phone_id + 1}: Audio playback enabled")
+ else:
+ self.debug(f"Phone {phone_id + 1}: Audio playback disabled")
+
+ def toggle_recording(self, phone_id):
+ """Toggle audio recording for a phone"""
+ is_recording, save_path = self.manager.toggle_recording(phone_id)
+ phone = self.manager.phones[phone_id]
+ phone['record_button'].setChecked(is_recording)
+
+ if is_recording:
+ self.debug(f"Phone {phone_id + 1}: Recording started")
+ else:
+ if save_path:
+ self.debug(f"Phone {phone_id + 1}: Recording saved to {save_path}")
+ else:
+ self.debug(f"Phone {phone_id + 1}: Recording stopped (no data)")
+
+ def show_audio_menu(self):
+ """Show audio processing options menu"""
+ menu = QMenu(self)
+
+ # Create phone selection submenu
+ for phone_id in range(2):
+ phone_menu = menu.addMenu(f"Phone {phone_id + 1}")
+
+ # Export buffer
+ export_action = QAction("Export Audio Buffer", self)
+ export_action.triggered.connect(lambda checked, pid=phone_id: self.export_audio_buffer(pid))
+ phone_menu.addAction(export_action)
+
+ # Clear buffer
+ clear_action = QAction("Clear Audio Buffer", self)
+ clear_action.triggered.connect(lambda checked, pid=phone_id: self.clear_audio_buffer(pid))
+ phone_menu.addAction(clear_action)
+
+ phone_menu.addSeparator()
+
+ # Processing options
+ normalize_action = QAction("Normalize Audio", self)
+ normalize_action.triggered.connect(lambda checked, pid=phone_id: self.process_audio(pid, "normalize"))
+ phone_menu.addAction(normalize_action)
+
+ gain_action = QAction("Apply Gain...", self)
+ gain_action.triggered.connect(lambda checked, pid=phone_id: self.apply_gain_dialog(pid))
+ phone_menu.addAction(gain_action)
+
+ noise_gate_action = QAction("Apply Noise Gate", self)
+ noise_gate_action.triggered.connect(lambda checked, pid=phone_id: self.process_audio(pid, "noise_gate"))
+ phone_menu.addAction(noise_gate_action)
+
+ low_pass_action = QAction("Apply Low Pass Filter", self)
+ low_pass_action.triggered.connect(lambda checked, pid=phone_id: self.process_audio(pid, "low_pass"))
+ phone_menu.addAction(low_pass_action)
+
+ high_pass_action = QAction("Apply High Pass Filter", self)
+ high_pass_action.triggered.connect(lambda checked, pid=phone_id: self.process_audio(pid, "high_pass"))
+ phone_menu.addAction(high_pass_action)
+
+ remove_silence_action = QAction("Remove Silence", self)
+ remove_silence_action.triggered.connect(lambda checked, pid=phone_id: self.process_audio(pid, "remove_silence"))
+ phone_menu.addAction(remove_silence_action)
+
+ # Show menu at button position
+ menu.exec_(self.audio_menu_button.mapToGlobal(self.audio_menu_button.rect().bottomLeft()))
+
+ def export_audio_buffer(self, phone_id):
+ """Export audio buffer for a phone"""
+ save_path = self.manager.export_buffered_audio(phone_id)
+ if save_path:
+ self.debug(f"Phone {phone_id + 1}: Audio buffer exported to {save_path}")
+ else:
+ self.debug(f"Phone {phone_id + 1}: No audio data to export")
+
+ def clear_audio_buffer(self, phone_id):
+ """Clear audio buffer for a phone"""
+ self.manager.clear_audio_buffer(phone_id)
+
+ def process_audio(self, phone_id, processing_type):
+ """Process audio with specified type"""
+ save_path = self.manager.process_audio(phone_id, processing_type)
+ if save_path:
+ self.debug(f"Phone {phone_id + 1}: Processed audio saved to {save_path}")
+ else:
+ self.debug(f"Phone {phone_id + 1}: Audio processing failed")
+
+ def apply_gain_dialog(self, phone_id):
+ """Show dialog to get gain value"""
+ gain, ok = QInputDialog.getDouble(
+ self, "Apply Gain", "Enter gain in dB:",
+ 0.0, -20.0, 20.0, 1
+ )
+ if ok:
+ save_path = self.manager.process_audio(phone_id, "gain", gain_db=gain)
+ if save_path:
+ self.debug(f"Phone {phone_id + 1}: Applied {gain}dB gain, saved to {save_path}")
+
+ def setup_shortcuts(self):
+ """Setup keyboard shortcuts"""
+ # Phone 1 shortcuts
+ QShortcut(QKeySequence("1"), self, lambda: self.manager.phone_action(0, self))
+ QShortcut(QKeySequence("Ctrl+1"), self, lambda: self.toggle_playback(0))
+ QShortcut(QKeySequence("Alt+1"), self, lambda: self.toggle_recording(0))
+
+ # Phone 2 shortcuts
+ QShortcut(QKeySequence("2"), self, lambda: self.manager.phone_action(1, self))
+ QShortcut(QKeySequence("Ctrl+2"), self, lambda: self.toggle_playback(1))
+ QShortcut(QKeySequence("Alt+2"), self, lambda: self.toggle_recording(1))
+
+ # General shortcuts
+ QShortcut(QKeySequence("Space"), self, self.toggle_auto_test)
+ QShortcut(QKeySequence("Ctrl+L"), self, self.clear_debug)
+ QShortcut(QKeySequence("Ctrl+A"), self, self.show_audio_menu)
+
+ self.debug("Keyboard shortcuts enabled:")
+ self.debug(" 1/2: Phone action (call/answer/hangup)")
+ self.debug(" Ctrl+1/2: Toggle playback")
+ self.debug(" Alt+1/2: Toggle recording")
+ self.debug(" Space: Toggle auto test")
+ self.debug(" Ctrl+L: Clear debug")
+ self.debug(" Ctrl+A: Audio options menu")
def closeEvent(self, event):
+ if self.auto_test_running:
+ self.stop_auto_test()
+ # Clean up audio player
+ if hasattr(self.manager, 'audio_player'):
+ self.manager.audio_player.cleanup()
for phone in self.manager.phones:
phone['client'].stop()
event.accept()
diff --git a/protocol_prototype/DryBox/UI/noise_wrapper.py b/protocol_prototype/DryBox/UI/noise_wrapper.py
new file mode 100644
index 0000000..dbcdb62
--- /dev/null
+++ b/protocol_prototype/DryBox/UI/noise_wrapper.py
@@ -0,0 +1,127 @@
+"""Wrapper for Noise XK handshake over GSM simulator"""
+
+import struct
+from dissononce.processing.impl.handshakestate import HandshakeState
+from dissononce.processing.impl.symmetricstate import SymmetricState
+from dissononce.processing.impl.cipherstate import CipherState
+from dissononce.processing.handshakepatterns.interactive.XK import XKHandshakePattern
+from dissononce.cipher.chachapoly import ChaChaPolyCipher
+from dissononce.dh.x25519.x25519 import X25519DH
+from dissononce.dh.keypair import KeyPair
+from dissononce.dh.x25519.public import PublicKey
+from dissononce.hash.sha256 import SHA256Hash
+
+class NoiseXKWrapper:
+ """Wrapper for Noise XK that works over message-passing instead of direct sockets"""
+
+ def __init__(self, keypair, peer_pubkey, debug_callback=None):
+ self.keypair = keypair
+ self.peer_pubkey = peer_pubkey
+ self.debug = debug_callback or print
+
+ # Build handshake state
+ cipher = ChaChaPolyCipher()
+ dh = X25519DH()
+ hshash = SHA256Hash()
+ symmetric = SymmetricState(CipherState(cipher), hshash)
+ self._hs = HandshakeState(symmetric, dh)
+
+ self._send_cs = None
+ self._recv_cs = None
+ self.handshake_complete = False
+ self.is_initiator = None # Track initiator status
+
+ # Message buffers
+ self.outgoing_messages = []
+ self.incoming_messages = []
+
+ def start_handshake(self, initiator):
+ """Start the handshake process"""
+ self.debug(f"Starting Noise XK handshake as {'initiator' if initiator else 'responder'}")
+ self.is_initiator = initiator # Store initiator status
+
+ if initiator:
+ # Initiator knows peer's static out-of-band
+ self._hs.initialize(
+ XKHandshakePattern(),
+ True,
+ b'',
+ s=self.keypair,
+ rs=self.peer_pubkey
+ )
+ # Generate first message
+ buf = bytearray()
+ self._hs.write_message(b'', buf)
+ self.outgoing_messages.append(bytes(buf))
+ self.debug(f"Generated handshake message 1: {len(buf)} bytes")
+ else:
+ # Responder doesn't know peer's static yet
+ self._hs.initialize(
+ XKHandshakePattern(),
+ False,
+ b'',
+ s=self.keypair
+ )
+ self.debug("Responder initialized, waiting for first message")
+
+ def process_handshake_message(self, data):
+ """Process incoming handshake message and generate response if needed"""
+ self.debug(f"Processing handshake message: {len(data)} bytes")
+
+ try:
+ # Read the message
+ payload = bytearray()
+ cs_pair = self._hs.read_message(data, payload)
+
+ # Check if we need to send a response
+ if not cs_pair:
+ # More messages needed
+ buf = bytearray()
+ cs_pair = self._hs.write_message(b'', buf)
+ self.outgoing_messages.append(bytes(buf))
+ self.debug(f"Generated handshake response: {len(buf)} bytes")
+
+ # Check if handshake completed after writing (for initiator)
+ if cs_pair:
+ self._complete_handshake(cs_pair)
+ else:
+ # Handshake complete after reading (for responder)
+ self._complete_handshake(cs_pair)
+
+ except Exception as e:
+ self.debug(f"Handshake error: {e}")
+ raise
+
+ def get_next_handshake_message(self):
+ """Get next outgoing handshake message"""
+ if self.outgoing_messages:
+ return self.outgoing_messages.pop(0)
+ return None
+
+ def encrypt(self, plaintext):
+ """Encrypt a message"""
+ if not self.handshake_complete:
+ raise RuntimeError("Handshake not complete")
+ return self._send_cs.encrypt_with_ad(b'', plaintext)
+
+ def decrypt(self, ciphertext):
+ """Decrypt a message"""
+ if not self.handshake_complete:
+ raise RuntimeError("Handshake not complete")
+ return self._recv_cs.decrypt_with_ad(b'', ciphertext)
+
+ def _complete_handshake(self, cs_pair):
+ """Complete the handshake with the given cipher states"""
+ self.debug("Handshake complete, setting up cipher states")
+ cs0, cs1 = cs_pair
+
+ # Use stored initiator status
+ if self.is_initiator:
+ self._send_cs, self._recv_cs = cs0, cs1
+ self.debug("Set up cipher states as initiator")
+ else:
+ self._send_cs, self._recv_cs = cs1, cs0
+ self.debug("Set up cipher states as responder")
+
+ self.handshake_complete = True
+ self.debug("Cipher states established")
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/UI/phone_client.py b/protocol_prototype/DryBox/UI/phone_client.py
deleted file mode 100644
index e4e9fbf..0000000
--- a/protocol_prototype/DryBox/UI/phone_client.py
+++ /dev/null
@@ -1,110 +0,0 @@
-import socket
-import time
-import select
-from PyQt5.QtCore import QThread, pyqtSignal
-from client_state import ClientState
-
-class PhoneClient(QThread):
- data_received = pyqtSignal(bytes, int)
- state_changed = pyqtSignal(str, str, int)
-
- def __init__(self, client_id):
- super().__init__()
- self.host = "localhost"
- self.port = 12345
- self.client_id = client_id
- self.sock = None
- self.running = True
- self.state = ClientState(client_id)
-
- def connect_socket(self):
- retries = 3
- for attempt in range(retries):
- try:
- self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
- self.sock.settimeout(120)
- self.sock.connect((self.host, self.port))
- print(f"Client {self.client_id} connected to {self.host}:{self.port}")
- return True
- except Exception as e:
- print(f"Client {self.client_id} connection attempt {attempt + 1} failed: {e}")
- if attempt < retries - 1:
- time.sleep(1)
- self.sock = None
- return False
-
- def run(self):
- while self.running:
- if not self.sock:
- if not self.connect_socket():
- print(f"Client {self.client_id} failed to connect after retries")
- self.state_changed.emit("CALL_END", "", self.client_id)
- break
- try:
- while self.running:
- self.state.process_command(self)
- self.state.check_handshake_timeout(self)
- if not self.state.handshake_in_progress:
- if self.sock is None:
- print(f"Client {self.client_id} socket is None, exiting inner loop")
- break
- readable, _, _ = select.select([self.sock], [], [], 0.01)
- if readable:
- try:
- if self.sock is None:
- print(f"Client {self.client_id} socket is None before recv, exiting")
- break
- data = self.sock.recv(1024)
- if not data:
- print(f"Client {self.client_id} disconnected")
- self.state_changed.emit("CALL_END", "", self.client_id)
- break
- self.state.handle_data(self, data)
- except socket.error as e:
- print(f"Client {self.client_id} socket error: {e}")
- self.state_changed.emit("CALL_END", "", self.client_id)
- break
- else:
- self.msleep(20)
- print(f"Client {self.client_id} yielding during handshake")
- self.msleep(1)
- except Exception as e:
- print(f"Client {self.client_id} unexpected error in run loop: {e}")
- self.state_changed.emit("CALL_END", "", self.client_id)
- break
- finally:
- if self.sock:
- try:
- self.sock.close()
- except Exception as e:
- print(f"Client {self.client_id} error closing socket: {e}")
- self.sock = None
-
- def send(self, message):
- if self.sock and self.running:
- try:
- if isinstance(message, str):
- data = message.encode('utf-8')
- self.sock.send(data)
- print(f"Client {self.client_id} sent: {message}, length={len(data)}")
- else:
- self.sock.send(message)
- print(f"Client {self.client_id} sent binary data, length={len(message)}")
- except socket.error as e:
- print(f"Client {self.client_id} send error: {e}")
- self.state_changed.emit("CALL_END", "", self.client_id)
-
- def stop(self):
- self.running = False
- if self.sock:
- try:
- self.sock.close()
- except Exception as e:
- print(f"Client {self.client_id} error closing socket in stop: {e}")
- self.sock = None
- self.quit()
- self.wait(1000)
-
- def start_handshake(self, initiator, keypair, peer_pubkey):
- self.state.start_handshake(initiator, keypair, peer_pubkey)
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/UI/phone_manager.py b/protocol_prototype/DryBox/UI/phone_manager.py
index 88a9730..d53f837 100644
--- a/protocol_prototype/DryBox/UI/phone_manager.py
+++ b/protocol_prototype/DryBox/UI/phone_manager.py
@@ -1,17 +1,37 @@
import secrets
from PyQt5.QtCore import QTimer
-from phone_client import PhoneClient
+from protocol_phone_client import ProtocolPhoneClient
from session import NoiseXKSession
from phone_state import PhoneState # Added import
+from audio_player import AudioPlayer
+from audio_processor import AudioProcessor
+import struct
+import wave
+import os
class PhoneManager:
def __init__(self):
self.phones = []
self.handshake_done_count = 0
+ self.ui = None # Will be set by UI
+ self.audio_player = AudioPlayer()
+ self.audio_player.set_debug_callback(self.debug)
+ self.audio_processor = AudioProcessor()
+ self.audio_processor.set_debug_callback(self.debug)
+ self.audio_buffer = {} # client_id -> list of audio chunks for processing
+ def debug(self, message):
+ """Send debug message to UI if available"""
+ if self.ui and hasattr(self.ui, 'debug'):
+ self.ui.debug(f"[PhoneManager] {message}")
+ else:
+ print(f"[PhoneManager] {message}")
+
def initialize_phones(self):
for i in range(2):
- client = PhoneClient(i)
+ client = ProtocolPhoneClient(i) # Use protocol client
+ client.set_debug_callback(self.debug) # Set debug callback
+ client.manager = self # Set manager reference for handshake lookup
keypair = NoiseXKSession.generate_keypair()
phone = {
'id': i,
@@ -21,9 +41,15 @@ class PhoneManager:
'audio_timer': None,
'keypair': keypair,
'public_key': keypair.public,
- 'is_initiator': False
+ 'is_initiator': False,
+ 'audio_file': None, # For test audio
+ 'frame_counter': 0,
+ 'playback_enabled': False,
+ 'recording_enabled': False
}
+ client.keypair = keypair # Also set keypair on client
self.phones.append(phone)
+ self.debug(f"Initialized Phone {i+1} with public key: {keypair.public.data.hex()[:32]}...")
self.phones[0]['peer_public_key'] = self.phones[1]['public_key']
self.phones[1]['peer_public_key'] = self.phones[0]['public_key']
@@ -31,16 +57,19 @@ class PhoneManager:
def phone_action(self, phone_id, ui_manager):
phone = self.phones[phone_id]
other_phone = self.phones[1 - phone_id]
- print(f"Phone {phone_id + 1} Action, current state: {phone['state']}, is_initiator: {phone['is_initiator']}")
+ self.debug(f"Phone {phone_id + 1} action triggered, current state: {phone['state'].name}")
if phone['state'] == PhoneState.IDLE:
+ self.debug(f"Phone {phone_id + 1} initiating call to Phone {2-phone_id}")
phone['state'] = PhoneState.CALLING
other_phone['state'] = PhoneState.RINGING
phone['is_initiator'] = True
other_phone['is_initiator'] = False
phone['client'].send("RINGING")
elif phone['state'] == PhoneState.RINGING:
- phone['state'] = other_phone['state'] = PhoneState.IN_CALL
+ self.debug(f"Phone {phone_id + 1} answering call from Phone {2-phone_id}")
+ phone['state'] = PhoneState.IN_CALL
+ # Don't set other_phone state here - let it set when it receives IN_CALL
phone['client'].send("IN_CALL")
elif phone['state'] in [PhoneState.IN_CALL, PhoneState.CALLING]:
if not phone['client'].state.handshake_in_progress and phone['state'] != PhoneState.CALLING:
@@ -49,40 +78,287 @@ class PhoneManager:
for p in [phone, other_phone]:
if p['audio_timer']:
p['audio_timer'].stop()
+ # End voice session
+ if p['client'].voice_active:
+ p['client'].end_voice_session()
+ # Close audio file
+ if p['audio_file']:
+ p['audio_file'].close()
+ p['audio_file'] = None
+ p['frame_counter'] = 0
else:
- print(f"Phone {phone_id + 1} cannot hang up during handshake or call setup")
+ self.debug(f"Phone {phone_id + 1} cannot hang up during handshake or call setup")
ui_manager.update_phone_ui(phone_id)
ui_manager.update_phone_ui(1 - phone_id)
def send_audio(self, phone_id):
phone = self.phones[phone_id]
- if phone['state'] == PhoneState.IN_CALL and phone['client'].state.session and phone['client'].sock:
- mock_audio = secrets.token_bytes(16)
- try:
+ if phone['state'] != PhoneState.IN_CALL:
+ self.debug(f"Phone {phone_id + 1} not in call, stopping audio timer")
+ if phone['audio_timer']:
+ phone['audio_timer'].stop()
+ return
+
+ if not phone['client'].handshake_complete:
+ self.debug(f"Phone {phone_id + 1} handshake not complete, skipping audio send")
+ return
+
+ if not phone['client'].voice_active:
+ self.debug(f"Phone {phone_id + 1} voice not active, skipping audio send")
+ return
+
+ if phone['state'] == PhoneState.IN_CALL and phone['client'].handshake_complete and phone['client'].voice_active:
+ # Load test audio file if not loaded
+ if phone['audio_file'] is None:
+ wav_path = "../wav/input.wav"
+ if not os.path.exists(wav_path):
+ wav_path = "wav/input.wav"
+ if os.path.exists(wav_path):
+ try:
+ phone['audio_file'] = wave.open(wav_path, 'rb')
+ self.debug(f"Phone {phone_id + 1} loaded test audio file: {wav_path}")
+ # Verify it's 8kHz mono
+ if phone['audio_file'].getframerate() != 8000:
+ self.debug(f"Warning: {wav_path} is {phone['audio_file'].getframerate()}Hz, expected 8000Hz")
+ if phone['audio_file'].getnchannels() != 1:
+ self.debug(f"Warning: {wav_path} has {phone['audio_file'].getnchannels()} channels, expected 1")
+
+ # Skip initial silence - jump to 1 second in (8000 samples)
+ phone['audio_file'].setpos(8000)
+ self.debug(f"Phone {phone_id + 1} skipped initial silence, starting at 1 second")
+ except Exception as e:
+ self.debug(f"Phone {phone_id + 1} failed to load audio: {e}")
+ # Use mock audio as fallback
+ phone['audio_file'] = None
+
+ # Read audio frame (40ms at 8kHz = 320 samples)
+ if phone['audio_file']:
+ try:
+ frames = phone['audio_file'].readframes(320)
+ if not frames or len(frames) < 640: # 320 samples * 2 bytes
+ # Loop back to 1 second (skip silence)
+ phone['audio_file'].setpos(8000)
+ frames = phone['audio_file'].readframes(320)
+ self.debug(f"Phone {phone_id + 1} looped audio back to 1 second mark")
+
+ # Send through protocol (codec + 4FSK + encryption)
+ phone['client'].send_voice_frame(frames)
+
+ # Update waveform
+ if len(frames) >= 2:
+ samples = struct.unpack(f'{len(frames)//2}h', frames)
+ self.update_sent_waveform(phone_id, frames)
+
+ # If playback is enabled on the sender, play the original audio
+ if phone['playback_enabled']:
+ self.audio_player.add_audio_data(phone_id, frames)
+ if phone['frame_counter'] % 25 == 0:
+ self.debug(f"Phone {phone_id + 1} playing original audio (sender playback)")
+
+ phone['frame_counter'] += 1
+ if phone['frame_counter'] % 25 == 0: # Log every second
+ self.debug(f"Phone {phone_id + 1} sent {phone['frame_counter']} voice frames")
+
+ except Exception as e:
+ self.debug(f"Phone {phone_id + 1} audio send error: {e}")
+ else:
+ # Fallback: send mock audio
+ mock_audio = secrets.token_bytes(320)
+ phone['client'].send_voice_frame(mock_audio)
self.update_sent_waveform(phone_id, mock_audio)
- phone['client'].state.session.send(phone['client'].sock, mock_audio)
- print(f"Client {phone_id} sent encrypted audio packet, length=32")
- except Exception as e:
- print(f"Client {phone_id} failed to send audio: {e}")
def start_audio(self, client_id, parent=None):
self.handshake_done_count += 1
- print(f"HANDSHAKE_DONE received for client {client_id}, count: {self.handshake_done_count}")
+ self.debug(f"HANDSHAKE_DONE received for client {client_id}, count: {self.handshake_done_count}")
+
+ # Start voice session for this client
+ phone = self.phones[client_id]
+ if phone['client'].handshake_complete and not phone['client'].voice_active:
+ phone['client'].start_voice_session()
+
if self.handshake_done_count == 2:
- for phone in self.phones:
- if phone['state'] == PhoneState.IN_CALL:
- if not phone['audio_timer'] or not phone['audio_timer'].isActive():
- phone['audio_timer'] = QTimer(parent) # Parent to PhoneUI
- phone['audio_timer'].timeout.connect(lambda pid=phone['id']: self.send_audio(pid))
- phone['audio_timer'].start(100)
+ # Add a small delay to ensure both sides are ready
+ def start_audio_timers():
+ self.debug("Starting audio timers for both phones")
+ for phone in self.phones:
+ if phone['state'] == PhoneState.IN_CALL:
+ if not phone['audio_timer'] or not phone['audio_timer'].isActive():
+ phone['audio_timer'] = QTimer(parent) # Parent to PhoneUI
+ phone['audio_timer'].timeout.connect(lambda pid=phone['id']: self.send_audio(pid))
+ phone['audio_timer'].start(40) # 40ms for each voice frame
+
+ # Delay audio start by 500ms to ensure both sides are ready
+ QTimer.singleShot(500, start_audio_timers)
self.handshake_done_count = 0
def update_waveform(self, client_id, data):
+ # Only process actual audio data (should be 640 bytes for 320 samples * 2 bytes)
+ # Ignore small control messages
+ if len(data) < 320: # Less than 160 samples (too small for audio)
+ self.debug(f"Phone {client_id + 1} received non-audio data: {len(data)} bytes (ignoring)")
+ return
+
self.phones[client_id]['waveform'].set_data(data)
+
+ # Debug log audio data reception (only occasionally to avoid spam)
+ if not hasattr(self, '_audio_frame_count'):
+ self._audio_frame_count = {}
+ if client_id not in self._audio_frame_count:
+ self._audio_frame_count[client_id] = 0
+ self._audio_frame_count[client_id] += 1
+
+ if self._audio_frame_count[client_id] == 1 or self._audio_frame_count[client_id] % 25 == 0:
+ self.debug(f"Phone {client_id + 1} received audio frame #{self._audio_frame_count[client_id]}: {len(data)} bytes")
+
+ # Store audio data in buffer for potential processing
+ if client_id not in self.audio_buffer:
+ self.audio_buffer[client_id] = []
+ self.audio_buffer[client_id].append(data)
+
+ # Keep buffer size reasonable (last 30 seconds at 8kHz)
+ max_chunks = 30 * 25 # 30 seconds * 25 chunks/second
+ if len(self.audio_buffer[client_id]) > max_chunks:
+ self.audio_buffer[client_id] = self.audio_buffer[client_id][-max_chunks:]
+
+ # Forward audio data to player if playback is enabled
+ if self.phones[client_id]['playback_enabled']:
+ if self._audio_frame_count[client_id] == 1:
+ self.debug(f"Phone {client_id + 1} forwarding audio to player (playback enabled)")
+ self.audio_player.add_audio_data(client_id, data)
def update_sent_waveform(self, client_id, data):
self.phones[client_id]['sent_waveform'].set_data(data)
+
+ def toggle_playback(self, client_id):
+ """Toggle audio playback for a phone"""
+ phone = self.phones[client_id]
+
+ if phone['playback_enabled']:
+ # Stop playback
+ self.audio_player.stop_playback(client_id)
+ phone['playback_enabled'] = False
+ self.debug(f"Phone {client_id + 1} playback stopped")
+ else:
+ # Start playback
+ if self.audio_player.start_playback(client_id):
+ phone['playback_enabled'] = True
+ self.debug(f"Phone {client_id + 1} playback started")
+ # Removed test beep - we want to hear actual audio
+ else:
+ self.debug(f"Phone {client_id + 1} failed to start playback")
+
+ return phone['playback_enabled']
+
+ def toggle_recording(self, client_id):
+ """Toggle audio recording for a phone"""
+ phone = self.phones[client_id]
+
+ if phone['recording_enabled']:
+ # Stop recording and save
+ save_path = self.audio_player.stop_recording(client_id)
+ phone['recording_enabled'] = False
+ if save_path:
+ self.debug(f"Phone {client_id + 1} recording saved to {save_path}")
+ return False, save_path
+ else:
+ # Start recording
+ self.audio_player.start_recording(client_id)
+ phone['recording_enabled'] = True
+ self.debug(f"Phone {client_id + 1} recording started")
+ return True, None
+
+ def save_received_audio(self, client_id, filename=None):
+ """Save the last received audio to a file"""
+ if client_id not in self.phones:
+ return None
+
+ save_path = self.audio_player.stop_recording(client_id, filename)
+ if save_path:
+ self.debug(f"Phone {client_id + 1} audio saved to {save_path}")
+ return save_path
+
+ def process_audio(self, client_id, processing_type, **kwargs):
+ """Process buffered audio with specified processing type"""
+ if client_id not in self.audio_buffer or not self.audio_buffer[client_id]:
+ self.debug(f"No audio data available for Phone {client_id + 1}")
+ return None
+
+ # Combine all audio chunks
+ combined_audio = b''.join(self.audio_buffer[client_id])
+
+ # Apply processing based on type
+ processed_audio = combined_audio
+
+ if processing_type == "normalize":
+ target_db = kwargs.get('target_db', -3)
+ processed_audio = self.audio_processor.normalize_audio(combined_audio, target_db)
+
+ elif processing_type == "gain":
+ gain_db = kwargs.get('gain_db', 0)
+ processed_audio = self.audio_processor.apply_gain(combined_audio, gain_db)
+
+ elif processing_type == "noise_gate":
+ threshold_db = kwargs.get('threshold_db', -40)
+ processed_audio = self.audio_processor.apply_noise_gate(combined_audio, threshold_db)
+
+ elif processing_type == "low_pass":
+ cutoff_hz = kwargs.get('cutoff_hz', 3400)
+ processed_audio = self.audio_processor.apply_low_pass_filter(combined_audio, cutoff_hz)
+
+ elif processing_type == "high_pass":
+ cutoff_hz = kwargs.get('cutoff_hz', 300)
+ processed_audio = self.audio_processor.apply_high_pass_filter(combined_audio, cutoff_hz)
+
+ elif processing_type == "remove_silence":
+ threshold_db = kwargs.get('threshold_db', -40)
+ processed_audio = self.audio_processor.remove_silence(combined_audio, threshold_db)
+
+ # Save processed audio
+ save_path = f"wav/phone{client_id + 1}_received.wav"
+ processed_path = self.audio_processor.save_processed_audio(
+ processed_audio, save_path, processing_type
+ )
+
+ return processed_path
+
+ def export_buffered_audio(self, client_id, filename=None):
+ """Export current audio buffer to file"""
+ if client_id not in self.audio_buffer or not self.audio_buffer[client_id]:
+ self.debug(f"No audio data available for Phone {client_id + 1}")
+ return None
+
+ # Combine all audio chunks
+ combined_audio = b''.join(self.audio_buffer[client_id])
+
+ # Generate filename if not provided
+ if not filename:
+ from datetime import datetime
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ filename = f"wav/phone{client_id + 1}_buffer_{timestamp}.wav"
+
+ # Ensure directory exists
+ os.makedirs(os.path.dirname(filename), exist_ok=True)
+
+ try:
+ with wave.open(filename, 'wb') as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(8000)
+ wav_file.writeframes(combined_audio)
+
+ self.debug(f"Exported audio buffer for Phone {client_id + 1} to {filename}")
+ return filename
+
+ except Exception as e:
+ self.debug(f"Failed to export audio buffer: {e}")
+ return None
+
+ def clear_audio_buffer(self, client_id):
+ """Clear audio buffer for a phone"""
+ if client_id in self.audio_buffer:
+ self.audio_buffer[client_id] = []
+ self.debug(f"Cleared audio buffer for Phone {client_id + 1}")
def map_state(self, state_str):
if state_str == "RINGING":
diff --git a/protocol_prototype/DryBox/UI/phone_state.py b/protocol_prototype/DryBox/UI/phone_state.py
index 56d40c1..c5d5a83 100644
--- a/protocol_prototype/DryBox/UI/phone_state.py
+++ b/protocol_prototype/DryBox/UI/phone_state.py
@@ -1,4 +1,6 @@
-class PhoneState:
+from enum import Enum
+
+class PhoneState(Enum):
IDLE = 0
CALLING = 1
IN_CALL = 2
diff --git a/protocol_prototype/DryBox/UI/protocol_client_state.py b/protocol_prototype/DryBox/UI/protocol_client_state.py
new file mode 100644
index 0000000..800cb8f
--- /dev/null
+++ b/protocol_prototype/DryBox/UI/protocol_client_state.py
@@ -0,0 +1,133 @@
+# protocol_client_state.py
+from queue import Queue
+from session import NoiseXKSession
+import time
+
+class ProtocolClientState:
+ """Enhanced client state for integrated protocol with voice codec"""
+
+ def __init__(self, client_id):
+ self.client_id = client_id
+ self.command_queue = Queue()
+ self.initiator = None
+ self.keypair = None
+ self.peer_pubkey = None
+ self.session = None
+ self.handshake_in_progress = False
+ self.handshake_start_time = None
+ self.call_active = False
+ self.voice_active = False
+ self.debug_callback = None
+
+ def debug(self, message):
+ """Send debug message"""
+ if self.debug_callback:
+ self.debug_callback(f"[State{self.client_id+1}] {message}")
+ else:
+ print(f"[State{self.client_id+1}] {message}")
+
+ def process_command(self, client):
+ """Process commands from the queue."""
+ if not self.command_queue.empty():
+ self.debug(f"Processing command queue, size: {self.command_queue.qsize()}")
+ command = self.command_queue.get()
+ self.debug(f"Processing command: {command}")
+
+ if command == "handshake":
+ # Handshake is now handled by the wrapper in the client
+ self.debug(f"Handshake command processed")
+ self.handshake_in_progress = False
+ self.handshake_start_time = None
+
+ elif command == "start_voice":
+ if client.handshake_complete:
+ client.start_voice_session()
+ self.voice_active = True
+
+ elif command == "end_voice":
+ if self.voice_active:
+ client.end_voice_session()
+ self.voice_active = False
+
+ def start_handshake(self, initiator, keypair, peer_pubkey):
+ """Queue handshake command."""
+ self.initiator = initiator
+ self.keypair = keypair
+ self.peer_pubkey = peer_pubkey
+ self.debug(f"Queuing handshake, initiator: {initiator}")
+ self.handshake_in_progress = True
+ self.handshake_start_time = time.time()
+ self.command_queue.put("handshake")
+
+ def handle_data(self, client, data):
+ """Handle received data (control or audio)."""
+ try:
+ # Try to decode as text first
+ decoded_data = data.decode('utf-8').strip()
+ self.debug(f"Received raw: {decoded_data}")
+
+ # Handle control messages
+ if decoded_data in ["RINGING", "CALL_END", "CALL_DROPPED", "IN_CALL", "HANDSHAKE", "HANDSHAKE_DONE"]:
+ self.debug(f"Emitting state change: {decoded_data}")
+ # Log which client is receiving what
+ self.debug(f"Client {self.client_id} received {decoded_data} message")
+ client.state_changed.emit(decoded_data, decoded_data, self.client_id)
+
+ if decoded_data == "IN_CALL":
+ self.debug(f"Received IN_CALL, setting call_active = True")
+ self.call_active = True
+ elif decoded_data == "HANDSHAKE":
+ self.debug(f"Received HANDSHAKE, setting handshake_in_progress = True")
+ self.handshake_in_progress = True
+ elif decoded_data == "HANDSHAKE_DONE":
+ self.debug(f"Received HANDSHAKE_DONE from peer")
+ self.call_active = True
+ # Start voice session on this side too
+ if client.handshake_complete and not client.voice_active:
+ self.debug(f"Starting voice session after receiving HANDSHAKE_DONE")
+ self.command_queue.put("start_voice")
+ elif decoded_data in ["CALL_END", "CALL_DROPPED"]:
+ self.debug(f"Received {decoded_data}, ending call")
+ self.call_active = False
+ if self.voice_active:
+ self.command_queue.put("end_voice")
+ else:
+ self.debug(f"Ignored unexpected text message: {decoded_data}")
+
+ except UnicodeDecodeError:
+ # Handle binary data (protocol messages or encrypted data)
+ if len(data) > 0 and data[0] == 0x20 and not client.handshake_complete: # Noise handshake message only before handshake completes
+ self.debug(f"Received Noise handshake message")
+ # Initialize responder if not already done
+ if not client.handshake_initiated:
+ # Find the other phone's public key
+ # This is a bit hacky but works for our 2-phone setup
+ manager = getattr(client, 'manager', None)
+ if manager:
+ other_phone = manager.phones[1 - self.client_id]
+ client.start_handshake(initiator=False,
+ keypair=client.keypair or manager.phones[self.client_id]['keypair'],
+ peer_pubkey=other_phone['public_key'])
+ # Pass to protocol handler
+ client._handle_protocol_message(data)
+ elif client.handshake_complete and client.noise_wrapper:
+ # Pass encrypted data back to client for decryption
+ client._handle_encrypted_data(data)
+ else:
+ # Pass other binary messages to protocol handler only if not yet complete
+ if not client.handshake_complete:
+ client._handle_protocol_message(data)
+
+ def check_handshake_timeout(self, client):
+ """Check for handshake timeout."""
+ if self.handshake_in_progress and self.handshake_start_time:
+ if time.time() - self.handshake_start_time > 30:
+ self.debug(f"Handshake timeout after 30s")
+ client.state_changed.emit("CALL_END", "", self.client_id)
+ self.handshake_in_progress = False
+ self.handshake_start_time = None
+
+ def queue_voice_command(self, command):
+ """Queue voice-related commands"""
+ if command in ["start_voice", "end_voice"]:
+ self.command_queue.put(command)
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/UI/protocol_phone_client.py b/protocol_prototype/DryBox/UI/protocol_phone_client.py
new file mode 100644
index 0000000..f8750b2
--- /dev/null
+++ b/protocol_prototype/DryBox/UI/protocol_phone_client.py
@@ -0,0 +1,456 @@
+import socket
+import time
+import select
+import struct
+import array
+from PyQt5.QtCore import QThread, pyqtSignal
+from protocol_client_state import ProtocolClientState
+from session import NoiseXKSession
+from noise_wrapper import NoiseXKWrapper
+from dissononce.dh.keypair import KeyPair
+from dissononce.dh.x25519.public import PublicKey
+import sys
+import os
+sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from voice_codec import Codec2Wrapper, FSKModem, Codec2Mode
+# ChaCha20 removed - using only Noise XK encryption
+
+class ProtocolPhoneClient(QThread):
+ """Integrated phone client with Noise XK, Codec2, 4FSK, and ChaCha20"""
+ data_received = pyqtSignal(bytes, int)
+ state_changed = pyqtSignal(str, str, int)
+
+ def __init__(self, client_id):
+ super().__init__()
+ self.host = "localhost"
+ self.port = 12345
+ self.client_id = client_id
+ self.sock = None
+ self.running = True
+ self.state = ProtocolClientState(client_id)
+
+ # Noise XK session
+ self.noise_session = None
+ self.noise_wrapper = None
+ self.handshake_complete = False
+ self.handshake_initiated = False
+
+ # No buffer needed with larger frame size
+
+ # Voice codec components
+ self.codec = Codec2Wrapper(mode=Codec2Mode.MODE_1200)
+ self.modem = FSKModem()
+
+ # Voice encryption handled by Noise XK
+ # No separate voice key needed
+
+ # Voice state
+ self.voice_active = False
+ self.voice_frame_counter = 0
+
+ # Message buffer for fragmented messages
+ self.recv_buffer = bytearray()
+
+ # Debug callback
+ self.debug_callback = None
+
+ def set_debug_callback(self, callback):
+ """Set debug callback function"""
+ self.debug_callback = callback
+ self.state.debug_callback = callback
+
+ def debug(self, message):
+ """Send debug message"""
+ if self.debug_callback:
+ self.debug_callback(f"[Phone{self.client_id+1}] {message}")
+ else:
+ print(f"[Phone{self.client_id+1}] {message}")
+
+ def connect_socket(self):
+ retries = 3
+ for attempt in range(retries):
+ try:
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
+ self.sock.settimeout(120)
+ self.sock.connect((self.host, self.port))
+ self.debug(f"Connected to GSM simulator at {self.host}:{self.port}")
+ return True
+ except Exception as e:
+ self.debug(f"Connection attempt {attempt + 1} failed: {e}")
+ if attempt < retries - 1:
+ time.sleep(1)
+ self.sock = None
+ return False
+
+ def run(self):
+ while self.running:
+ if not self.sock:
+ if not self.connect_socket():
+ self.debug("Failed to connect after retries")
+ self.state_changed.emit("CALL_END", "", self.client_id)
+ break
+ try:
+ while self.running:
+ self.state.process_command(self)
+ self.state.check_handshake_timeout(self)
+
+ if self.handshake_complete and self.voice_active:
+ # Process voice data if active
+ self._process_voice_data()
+
+ # Always check for incoming data, even during handshake
+ if self.sock is None:
+ break
+ readable, _, _ = select.select([self.sock], [], [], 0.01)
+ if readable:
+ try:
+ if self.sock is None:
+ break
+ chunk = self.sock.recv(4096)
+ if not chunk:
+ self.debug("Disconnected from server")
+ self.state_changed.emit("CALL_END", "", self.client_id)
+ break
+
+ # Add to buffer
+ self.recv_buffer.extend(chunk)
+
+ # Process complete messages
+ while len(self.recv_buffer) >= 4:
+ # Read message length
+ msg_len = struct.unpack('>I', self.recv_buffer[:4])[0]
+
+ # Check if we have the complete message
+ if len(self.recv_buffer) >= 4 + msg_len:
+ # Extract message
+ data = bytes(self.recv_buffer[4:4+msg_len])
+ # Remove from buffer
+ self.recv_buffer = self.recv_buffer[4+msg_len:]
+ # Pass to state handler
+ self.state.handle_data(self, data)
+ else:
+ # Wait for more data
+ break
+
+ except socket.error as e:
+ self.debug(f"Socket error: {e}")
+ self.state_changed.emit("CALL_END", "", self.client_id)
+ break
+
+ self.msleep(1)
+ except Exception as e:
+ self.debug(f"Unexpected error in run loop: {e}")
+ self.state_changed.emit("CALL_END", "", self.client_id)
+ break
+ finally:
+ if self.sock:
+ try:
+ self.sock.close()
+ except Exception as e:
+ self.debug(f"Error closing socket: {e}")
+ self.sock = None
+
+ def _handle_encrypted_data(self, data):
+ """Handle encrypted data after handshake"""
+ if not self.handshake_complete or not self.noise_wrapper:
+ self.debug(f"Cannot decrypt - handshake not complete")
+ return
+
+ # All data after handshake is encrypted, decrypt it first
+ try:
+ plaintext = self.noise_wrapper.decrypt(data)
+
+ # Check if it's a text message
+ try:
+ text_msg = plaintext.decode('utf-8').strip()
+ if text_msg == "HANDSHAKE_DONE":
+ self.debug(f"Received encrypted HANDSHAKE_DONE")
+ self.state_changed.emit("HANDSHAKE_DONE", "HANDSHAKE_DONE", self.client_id)
+ return
+ except:
+ pass
+
+ # Otherwise handle as protocol message
+ self._handle_protocol_message(plaintext)
+ except Exception as e:
+ # Suppress common decryption errors
+ pass
+
+ def _handle_protocol_message(self, plaintext):
+ """Handle decrypted protocol messages"""
+ if len(plaintext) < 1:
+ return
+
+ msg_type = plaintext[0]
+ msg_data = plaintext[1:]
+
+ if msg_type == 0x10: # Voice start
+ self.debug("Received VOICE_START message")
+ self._handle_voice_start(msg_data)
+ elif msg_type == 0x11: # Voice data
+ self._handle_voice_data(msg_data)
+ elif msg_type == 0x12: # Voice end
+ self.debug("Received VOICE_END message")
+ self._handle_voice_end(msg_data)
+ elif msg_type == 0x20: # Noise handshake
+ self.debug("Received NOISE_HS message")
+ self._handle_noise_handshake(msg_data)
+ else:
+ self.debug(f"Received unknown protocol message type: 0x{msg_type:02x}")
+ # Don't emit control messages to data_received - that's only for audio
+ # Control messages should be handled via state_changed signal
+
+ def _handle_voice_start(self, data):
+ """Handle voice session start"""
+ self.debug("Voice session started by peer")
+ self.voice_active = True
+ self.voice_frame_counter = 0
+ self.state_changed.emit("VOICE_START", "", self.client_id)
+
+ def _handle_voice_data(self, data):
+ """Handle voice frame (already decrypted by Noise)"""
+ if len(data) < 4:
+ return
+
+ try:
+ # Data is float array packed as bytes
+ # Unpack the float array
+ num_floats = len(data) // 4
+ modulated_signal = struct.unpack(f'{num_floats}f', data)
+
+ # Demodulate FSK
+ demodulated_data, confidence = self.modem.demodulate(modulated_signal)
+
+ if confidence > 0.5: # Only decode if confidence is good
+ # Create Codec2Frame from demodulated data
+ from voice_codec import Codec2Frame, Codec2Mode
+ frame = Codec2Frame(
+ mode=Codec2Mode.MODE_1200,
+ bits=demodulated_data,
+ timestamp=time.time(),
+ frame_number=self.voice_frame_counter
+ )
+
+ # Decode with Codec2
+ pcm_samples = self.codec.decode(frame)
+
+ if self.voice_frame_counter == 0:
+ self.debug(f"First voice frame demodulated with confidence {confidence:.2f}")
+
+ # Send PCM to UI for playback
+ if pcm_samples is not None and len(pcm_samples) > 0:
+ # Only log details for first frame and every 25th frame
+ if self.voice_frame_counter == 0 or self.voice_frame_counter % 25 == 0:
+ self.debug(f"Decoded PCM samples: type={type(pcm_samples)}, len={len(pcm_samples)}")
+
+ # Convert to bytes if needed
+ if hasattr(pcm_samples, 'tobytes'):
+ pcm_bytes = pcm_samples.tobytes()
+ elif isinstance(pcm_samples, (list, array.array)):
+ # Convert array to bytes
+ import array
+ if isinstance(pcm_samples, list):
+ pcm_array = array.array('h', pcm_samples)
+ pcm_bytes = pcm_array.tobytes()
+ else:
+ pcm_bytes = pcm_samples.tobytes()
+ else:
+ pcm_bytes = bytes(pcm_samples)
+
+ if self.voice_frame_counter == 0:
+ self.debug(f"Emitting first PCM frame: {len(pcm_bytes)} bytes")
+
+ self.data_received.emit(pcm_bytes, self.client_id)
+ self.voice_frame_counter += 1
+ # Log frame reception periodically
+ if self.voice_frame_counter == 1 or self.voice_frame_counter % 25 == 0:
+ self.debug(f"Received voice data frame #{self.voice_frame_counter}")
+ else:
+ self.debug(f"Codec decode returned None or empty")
+ else:
+ if self.voice_frame_counter % 10 == 0:
+ self.debug(f"Low confidence demodulation: {confidence:.2f}")
+
+ except Exception as e:
+ self.debug(f"Voice decode error: {e}")
+
+ def _handle_voice_end(self, data):
+ """Handle voice session end"""
+ self.debug("Voice session ended by peer")
+ self.voice_active = False
+ self.state_changed.emit("VOICE_END", "", self.client_id)
+
+ def _handle_noise_handshake(self, data):
+ """Handle Noise handshake message"""
+ if not self.noise_wrapper:
+ self.debug("Received handshake message but no wrapper initialized")
+ return
+
+ try:
+ # Process the handshake message
+ self.noise_wrapper.process_handshake_message(data)
+
+ # Check if we need to send a response
+ response = self.noise_wrapper.get_next_handshake_message()
+ if response:
+ self.send(b'\x20' + response)
+
+ # Check if handshake is complete
+ if self.noise_wrapper.handshake_complete and not self.handshake_complete:
+ self.debug("Noise wrapper handshake complete, calling complete_handshake()")
+ self.complete_handshake()
+
+ except Exception as e:
+ self.debug(f"Handshake processing error: {e}")
+ self.state_changed.emit("CALL_END", "", self.client_id)
+
+ def _process_voice_data(self):
+ """Process outgoing voice data"""
+ # This would be called when we have voice input to send
+ # For now, this is a placeholder
+ pass
+
+ def send_voice_frame(self, pcm_samples):
+ """Send a voice frame through the protocol"""
+ if not self.handshake_complete:
+ self.debug("Cannot send voice - handshake not complete")
+ return
+ if not self.voice_active:
+ self.debug("Cannot send voice - voice session not active")
+ return
+
+ try:
+ # Encode with Codec2
+ codec_frame = self.codec.encode(pcm_samples)
+ if not codec_frame:
+ return
+
+ if self.voice_frame_counter % 25 == 0: # Log every 25 frames (1 second)
+ self.debug(f"Encoding voice frame #{self.voice_frame_counter}: {len(pcm_samples)} bytes PCM โ {len(codec_frame.bits)} bytes compressed")
+
+ # Modulate with FSK
+ modulated_data = self.modem.modulate(codec_frame.bits)
+
+ # Convert modulated float array to bytes
+ modulated_bytes = struct.pack(f'{len(modulated_data)}f', *modulated_data)
+
+ if self.voice_frame_counter % 25 == 0:
+ self.debug(f"Voice frame size: {len(modulated_bytes)} bytes")
+
+ # Build voice data message (no ChaCha20, will be encrypted by Noise)
+ msg = bytes([0x11]) + modulated_bytes
+
+ # Send through Noise encrypted channel
+ self.send(msg)
+
+ self.voice_frame_counter += 1
+
+ except Exception as e:
+ self.debug(f"Voice encode error: {e}")
+
+ def send(self, message):
+ """Send data through Noise encrypted channel with proper framing"""
+ if self.sock and self.running:
+ try:
+ # Handshake messages (0x20) bypass Noise encryption
+ if isinstance(message, bytes) and len(message) > 0 and message[0] == 0x20:
+ # Add length prefix for framing
+ framed = struct.pack('>I', len(message)) + message
+ self.sock.send(framed)
+ return
+
+ if self.handshake_complete and self.noise_wrapper:
+ # Encrypt everything with Noise after handshake
+ # Convert string to bytes if needed
+ if isinstance(message, str):
+ message = message.encode('utf-8')
+ encrypted = self.noise_wrapper.encrypt(message)
+ # Add length prefix for framing
+ framed = struct.pack('>I', len(encrypted)) + encrypted
+ self.sock.send(framed)
+ else:
+ # During handshake, send raw with framing
+ if isinstance(message, str):
+ data = message.encode('utf-8')
+ framed = struct.pack('>I', len(data)) + data
+ self.sock.send(framed)
+ self.debug(f"Sent control message: {message}")
+ else:
+ framed = struct.pack('>I', len(message)) + message
+ self.sock.send(framed)
+ except socket.error as e:
+ self.debug(f"Send error: {e}")
+ self.state_changed.emit("CALL_END", "", self.client_id)
+
+ def stop(self):
+ self.running = False
+ self.voice_active = False
+ if self.sock:
+ try:
+ self.sock.close()
+ except Exception as e:
+ self.debug(f"Error closing socket in stop: {e}")
+ self.sock = None
+ self.quit()
+ self.wait(1000)
+
+ def start_handshake(self, initiator, keypair, peer_pubkey):
+ """Start Noise XK handshake"""
+ self.debug(f"Starting Noise XK handshake as {'initiator' if initiator else 'responder'}")
+ self.debug(f"Our public key: {keypair.public.data.hex()[:32]}...")
+ self.debug(f"Peer public key: {peer_pubkey.data.hex()[:32]}...")
+
+ # Create noise wrapper
+ self.noise_wrapper = NoiseXKWrapper(keypair, peer_pubkey, self.debug)
+ self.noise_wrapper.start_handshake(initiator)
+ self.handshake_initiated = True
+
+ # Send first handshake message if initiator
+ if initiator:
+ msg = self.noise_wrapper.get_next_handshake_message()
+ if msg:
+ # Send as NOISE_HS message type
+ self.send(b'\x20' + msg) # 0x20 = Noise handshake message
+
+ def complete_handshake(self):
+ """Called when Noise handshake completes"""
+ self.handshake_complete = True
+
+ self.debug("Noise XK handshake complete!")
+ self.debug("Secure channel established")
+
+ # Send HANDSHAKE_DONE message
+ self.send("HANDSHAKE_DONE")
+
+ self.state_changed.emit("HANDSHAKE_COMPLETE", "", self.client_id)
+
+ def start_voice_session(self):
+ """Start a voice session"""
+ if not self.handshake_complete:
+ self.debug("Cannot start voice - handshake not complete")
+ return
+
+ self.voice_active = True
+ self.voice_frame_counter = 0
+
+ # Send voice start message
+ msg = bytes([0x10]) # Voice start message type
+ self.send(msg)
+
+ self.debug("Voice session started")
+ self.state_changed.emit("VOICE_START", "", self.client_id)
+
+ def end_voice_session(self):
+ """End a voice session"""
+ if not self.voice_active:
+ return
+
+ self.voice_active = False
+
+ # Send voice end message
+ msg = bytes([0x12]) # Voice end message type
+ self.send(msg)
+
+ self.debug("Voice session ended")
+ self.state_changed.emit("VOICE_END", "", self.client_id)
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/UI/session.py b/protocol_prototype/DryBox/UI/session.py
index 5fc48c2..86a4a3f 100644
--- a/protocol_prototype/DryBox/UI/session.py
+++ b/protocol_prototype/DryBox/UI/session.py
@@ -10,8 +10,8 @@ from dissononce.dh.keypair import KeyPair
from dissononce.dh.x25519.public import PublicKey
from dissononce.hash.sha256 import SHA256Hash
-# Configure root logger for debug output
-logging.basicConfig(level=logging.DEBUG, format="%(message)s")
+# Configure logging - disabled by default to avoid noise
+# logging.basicConfig(level=logging.DEBUG, format="%(message)s")
class NoiseXKSession:
@staticmethod
@@ -46,7 +46,7 @@ class NoiseXKSession:
so that each side reads or writes in the correct message order.
On completion, self._send_cs and self._recv_cs hold the two CipherStates.
"""
- logging.debug(f"[handshake] start (initiator={initiator})")
+ # logging.debug(f"[handshake] start (initiator={initiator})")
# initialize with our KeyPair and their PublicKey
if initiator:
# initiator knows peerโs static out-of-band
@@ -58,7 +58,7 @@ class NoiseXKSession:
rs=self.peer_pubkey
)
else:
- logging.debug("[handshake] responder initializing without rs")
+ # logging.debug("[handshake] responder initializing without rs")
# responder must NOT supply rs here
self._hs.initialize(
XKHandshakePattern(),
@@ -72,34 +72,34 @@ class NoiseXKSession:
# 1) -> e
buf1 = bytearray()
cs_pair = self._hs.write_message(b'', buf1)
- logging.debug(f"[-> e] {buf1.hex()}")
+ # logging.debug(f"[-> e] {buf1.hex()}")
self._send_all(sock, buf1)
# 2) <- e, es, s, ss
msg2 = self._recv_all(sock)
- logging.debug(f"[<- msg2] {msg2.hex()}")
+ # logging.debug(f"[<- msg2] {msg2.hex()}")
self._hs.read_message(msg2, bytearray())
# 3) -> se (final)
buf3 = bytearray()
cs_pair = self._hs.write_message(b'', buf3)
- logging.debug(f"[-> se] {buf3.hex()}")
+ # logging.debug(f"[-> se] {buf3.hex()}")
self._send_all(sock, buf3)
else:
# 1) <- e
msg1 = self._recv_all(sock)
- logging.debug(f"[<- e] {msg1.hex()}")
+ # logging.debug(f"[<- e] {msg1.hex()}")
self._hs.read_message(msg1, bytearray())
# 2) -> e, es, s, ss
buf2 = bytearray()
cs_pair = self._hs.write_message(b'', buf2)
- logging.debug(f"[-> msg2] {buf2.hex()}")
+ # logging.debug(f"[-> msg2] {buf2.hex()}")
self._send_all(sock, buf2)
# 3) <- se (final)
msg3 = self._recv_all(sock)
- logging.debug(f"[<- se] {msg3.hex()}")
+ # logging.debug(f"[<- se] {msg3.hex()}")
cs_pair = self._hs.read_message(msg3, bytearray())
# on the final step, we must get exactly two CipherStates
@@ -168,9 +168,9 @@ class NoiseXKSession:
# Read 2-byte length prefix, then the payload
hdr = self._read_exact(sock, 2)
length = int.from_bytes(hdr, 'big')
- logging.debug(f"[RECV] length={length} ({hdr.hex()})")
+ # logging.debug(f"[RECV] length={length} ({hdr.hex()})")
data = self._read_exact(sock, length)
- logging.debug(f"[RECV] data={data.hex()}")
+ # logging.debug(f"[RECV] data={data.hex()}")
return data
@staticmethod
diff --git a/protocol_prototype/DryBox/UI/waveform_widget.py b/protocol_prototype/DryBox/UI/waveform_widget.py
index 9b26240..bb507a0 100644
--- a/protocol_prototype/DryBox/UI/waveform_widget.py
+++ b/protocol_prototype/DryBox/UI/waveform_widget.py
@@ -1,4 +1,5 @@
import random
+import struct
from PyQt5.QtWidgets import QWidget
from PyQt5.QtCore import QTimer, QSize, QPointF
from PyQt5.QtGui import QPainter, QColor, QPen, QLinearGradient, QBrush
@@ -7,8 +8,8 @@ class WaveformWidget(QWidget):
def __init__(self, parent=None, dynamic=False):
super().__init__(parent)
self.dynamic = dynamic
- self.setMinimumSize(200, 80)
- self.setMaximumHeight(100)
+ self.setMinimumSize(200, 60)
+ self.setMaximumHeight(80)
self.waveform_data = [random.randint(10, 90) for _ in range(50)]
if self.dynamic:
self.timer = QTimer(self)
@@ -20,8 +21,28 @@ class WaveformWidget(QWidget):
self.update()
def set_data(self, data):
- amplitude = sum(byte for byte in data) % 90 + 10
- self.waveform_data = self.waveform_data[1:] + [amplitude]
+ # Convert audio data to visual amplitude
+ if isinstance(data, bytes) and len(data) >= 2:
+ # Extract PCM samples (16-bit signed)
+ num_samples = min(len(data) // 2, 20) # Take up to 20 samples
+ samples = []
+ for i in range(0, num_samples * 2, 2):
+ if i + 1 < len(data):
+ sample = struct.unpack('h', data[i:i+2])[0]
+ # Normalize to 0-100 range
+ amplitude = abs(sample) / 327.68 # 32768/100
+ samples.append(min(95, max(5, amplitude)))
+
+ if samples:
+ # Add new samples and maintain fixed size
+ self.waveform_data.extend(samples)
+ # Keep last 50 samples
+ self.waveform_data = self.waveform_data[-50:]
+ else:
+ # Fallback for non-audio data
+ amplitude = sum(byte for byte in data[:20]) % 90 + 10
+ self.waveform_data = self.waveform_data[1:] + [amplitude]
+
self.update()
def paintEvent(self, event):
diff --git a/protocol_prototype/DryBox/devnote.txt b/protocol_prototype/DryBox/devnote.txt
deleted file mode 100644
index ae75b1c..0000000
--- a/protocol_prototype/DryBox/devnote.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-simulator/
-โโโ gsm_simulator.py # gsm_simulator
-โโโ launch_gsm_simulator.sh # use to start docker and simulator, run in terminal
-
-2 clients nect to gsm_simulator and simulate a call using noise protocol
-UI/
-โโโ main.py # UI setup and event handling
-โโโ phone_manager.py # Phone state, client init, audio logic
-โโโ phone_client.py # Socket communication and threading
-โโโ client_state.py # Client state and command processing
-โโโ session.py # Noise XK crypto session
-โโโ waveform_widget.py # Waveform UI component
-โโโ phone_state.py # State constants
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/install_audio_deps.sh b/protocol_prototype/DryBox/install_audio_deps.sh
new file mode 100644
index 0000000..8d4d5eb
--- /dev/null
+++ b/protocol_prototype/DryBox/install_audio_deps.sh
@@ -0,0 +1,58 @@
+#!/bin/bash
+# Install audio dependencies for DryBox
+
+echo "Installing audio dependencies for DryBox..."
+echo
+
+# Detect OS
+if [ -f /etc/os-release ]; then
+ . /etc/os-release
+ OS=$ID
+ VER=$VERSION_ID
+else
+ echo "Cannot detect OS. Please install manually."
+ exit 1
+fi
+
+case $OS in
+ fedora)
+ echo "Detected Fedora $VER"
+ echo "Installing python3-devel and portaudio-devel..."
+ sudo dnf install -y python3-devel portaudio-devel
+ ;;
+
+ ubuntu|debian)
+ echo "Detected $OS $VER"
+ echo "Installing python3-dev and portaudio19-dev..."
+ sudo apt-get update
+ sudo apt-get install -y python3-dev portaudio19-dev
+ ;;
+
+ *)
+ echo "Unsupported OS: $OS"
+ echo "Please install manually:"
+ echo " - Python development headers"
+ echo " - PortAudio development libraries"
+ exit 1
+ ;;
+esac
+
+if [ $? -eq 0 ]; then
+ echo
+ echo "System dependencies installed successfully!"
+ echo "Now installing PyAudio..."
+ pip install pyaudio
+
+ if [ $? -eq 0 ]; then
+ echo
+ echo "โ
Audio dependencies installed successfully!"
+ echo "You can now use real-time audio playback in DryBox."
+ else
+ echo
+ echo "โ Failed to install PyAudio"
+ echo "Try: pip install --user pyaudio"
+ fi
+else
+ echo
+ echo "โ Failed to install system dependencies"
+fi
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/requirements.txt b/protocol_prototype/DryBox/requirements.txt
new file mode 100644
index 0000000..af3c25b
--- /dev/null
+++ b/protocol_prototype/DryBox/requirements.txt
@@ -0,0 +1,22 @@
+# Core dependencies for DryBox integrated protocol
+
+# Noise Protocol Framework
+dissononce>=0.34.3
+
+# Cryptography
+cryptography>=41.0.0
+
+# Qt GUI
+PyQt5>=5.15.0
+
+# Numerical computing (for signal processing)
+numpy>=1.24.0
+
+# Audio processing (for real audio I/O)
+pyaudio>=0.2.11
+
+# Wave file handling (included in standard library)
+# wave
+
+# For future integration with real Codec2
+# pycodec2>=1.0.0
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/run_ui.sh b/protocol_prototype/DryBox/run_ui.sh
new file mode 100644
index 0000000..2b3beb1
--- /dev/null
+++ b/protocol_prototype/DryBox/run_ui.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+# Run DryBox UI with proper Wayland support on Fedora
+
+cd "$(dirname "$0")"
+
+# Use native Wayland if available
+export QT_QPA_PLATFORM=wayland
+
+# Run the UI
+cd UI
+python3 main.py
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/simulator/gsm_simulator.py b/protocol_prototype/DryBox/simulator/gsm_simulator.py
index e0cc4d3..03bf5cb 100644
--- a/protocol_prototype/DryBox/simulator/gsm_simulator.py
+++ b/protocol_prototype/DryBox/simulator/gsm_simulator.py
@@ -1,10 +1,11 @@
import socket
import threading
import time
+import struct
HOST = "0.0.0.0"
PORT = 12345
-FRAME_SIZE = 1000
+FRAME_SIZE = 10000 # Increased to avoid fragmenting voice frames
FRAME_DELAY = 0.02
clients = []
@@ -12,25 +13,49 @@ clients_lock = threading.Lock()
def handle_client(client_sock, client_id):
print(f"Starting handle_client for Client {client_id}")
+ recv_buffer = bytearray()
+
try:
while True:
other_client = None
with clients_lock:
if len(clients) == 2 and client_id < len(clients):
other_client = clients[1 - client_id]
- print(f"Client {client_id} waiting for data, other_client exists: {other_client is not None}")
try:
- data = client_sock.recv(1024)
- if not data:
+ chunk = client_sock.recv(4096)
+ if not chunk:
print(f"Client {client_id} disconnected or no data received")
break
- if other_client:
- for i in range(0, len(data), FRAME_SIZE):
- frame = data[i:i + FRAME_SIZE]
- other_client.send(frame)
- time.sleep(FRAME_DELAY)
- print(f"Forwarded {len(data)} bytes from Client {client_id} to Client {1 - client_id}")
+
+ # Add to buffer
+ recv_buffer.extend(chunk)
+
+ # Process complete messages
+ while len(recv_buffer) >= 4:
+ # Read message length
+ msg_len = struct.unpack('>I', recv_buffer[:4])[0]
+
+ # Check if we have the complete message
+ if len(recv_buffer) >= 4 + msg_len:
+ # Extract complete message (including length prefix)
+ complete_msg = bytes(recv_buffer[:4+msg_len])
+ # Remove from buffer
+ recv_buffer = recv_buffer[4+msg_len:]
+
+ # Forward complete message to other client
+ if other_client:
+ try:
+ other_client.send(complete_msg)
+ print(f"Forwarded {len(complete_msg)} bytes from Client {client_id} to Client {1 - client_id}")
+ except Exception as e:
+ print(f"Error forwarding from Client {client_id}: {e}")
+ else:
+ print(f"No other client to forward to from Client {client_id}")
+ else:
+ # Wait for more data
+ break
+
except socket.error as e:
print(f"Socket error with Client {client_id}: {e}")
break
diff --git a/protocol_prototype/DryBox/unused/external_caller.py b/protocol_prototype/DryBox/unused/external_caller.py
deleted file mode 100644
index 9b06026..0000000
--- a/protocol_prototype/DryBox/unused/external_caller.py
+++ /dev/null
@@ -1,24 +0,0 @@
-#external_caller.py
-import socket
-import time
-
-
-def connect():
- caller_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- caller_socket.connect(('localhost', 12345))
- caller_socket.send("CALLER".encode())
- print("Connected to GSM simulator as CALLER")
- time.sleep(2) # Wait 2 seconds for receiver to connect
-
- for i in range(5):
- message = f"Audio packet {i + 1}"
- caller_socket.send(message.encode())
- print(f"Sent: {message}")
- time.sleep(1)
-
- caller_socket.send("CALL_END".encode())
- print("Call ended.")
- caller_socket.close()
-
-if __name__ == "__main__":
- connect()
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/unused/external_receiver.py b/protocol_prototype/DryBox/unused/external_receiver.py
deleted file mode 100644
index 20c02de..0000000
--- a/protocol_prototype/DryBox/unused/external_receiver.py
+++ /dev/null
@@ -1,37 +0,0 @@
-#external_receiver.py
-import socket
-
-def connect():
- receiver_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- receiver_socket.settimeout(15) # Increase timeout to 15 seconds
- receiver_socket.connect(('localhost', 12345))
- receiver_socket.send("RECEIVER".encode())
- print("Connected to GSM simulator as RECEIVER")
-
- while True:
- try:
- data = receiver_socket.recv(1024).decode().strip()
- if not data:
- print("No data received. Connection closed.")
- break
- if data == "RINGING":
- print("Incoming call... ringing")
- elif data == "CALL_END":
- print("Call ended by caller.")
- break
- elif data == "CALL_DROPPED":
- print("Call dropped by network.")
- break
- else:
- print(f"Received: {data}")
- except socket.timeout:
- print("Timed out waiting for data.")
- break
- except Exception as e:
- print(f"Receiver error: {e}")
- break
-
- receiver_socket.close()
-
-if __name__ == "__main__":
- connect()
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/unused/protocol.py b/protocol_prototype/DryBox/unused/protocol.py
deleted file mode 100644
index 4c3cc79..0000000
--- a/protocol_prototype/DryBox/unused/protocol.py
+++ /dev/null
@@ -1,86 +0,0 @@
-import socket
-import os
-import time
-import subprocess
-
-# Configuration
-HOST = "localhost"
-PORT = 12345
-INPUT_FILE = "wav/input.wav"
-OUTPUT_FILE = "wav/received.wav"
-
-
-def encrypt_data(data):
- return data # Replace with your encryption protocol
-
-
-def decrypt_data(data):
- return data # Replace with your decryption protocol
-
-
-def run_protocol(send_mode=True):
- """Connect to the simulator and send/receive data."""
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.connect((HOST, PORT))
- print(f"Connected to simulator at {HOST}:{PORT}")
-
- if send_mode:
- # Sender mode: Encode audio with toast
- os.system(f"toast -p -l {INPUT_FILE}") # Creates input.wav.gsm
- input_gsm_file = f"{INPUT_FILE}.gsm"
- if not os.path.exists(input_gsm_file):
- print(f"Error: {input_gsm_file} not created")
- sock.close()
- return
- with open(input_gsm_file, "rb") as f:
- voice_data = f.read()
-
- encrypted_data = encrypt_data(voice_data)
- sock.send(encrypted_data)
- print(f"Sent {len(encrypted_data)} bytes")
- os.remove(input_gsm_file) # Clean up
- else:
- # Receiver mode: Wait for and receive data
- print("Waiting for data from sender...")
- received_data = b""
- sock.settimeout(5.0)
- try:
- while True:
- print("Calling recv()...")
- data = sock.recv(1024)
- print(f"Received {len(data)} bytes")
- if not data:
- print("Connection closed by sender or simulator")
- break
- received_data += data
- except socket.timeout:
- print("Timed out waiting for data")
-
- if received_data:
- with open("received.gsm", "wb") as f:
- f.write(decrypt_data(received_data))
- print(f"Wrote {len(received_data)} bytes to received.gsm")
- # Decode with untoast, then convert to WAV with sox
- result = subprocess.run(["untoast", "received.gsm"], capture_output=True, text=True)
- print(f"untoast return code: {result.returncode}")
- print(f"untoast stderr: {result.stderr}")
- if result.returncode == 0:
- if os.path.exists("received"):
- # Convert raw PCM to WAV (8 kHz, mono, 16-bit)
- subprocess.run(["sox", "-t", "raw", "-r", "8000", "-e", "signed", "-b", "16", "-c", "1", "received",
- OUTPUT_FILE])
- os.remove("received")
- print(f"Received and saved {len(received_data)} bytes to {OUTPUT_FILE}")
- else:
- print("Error: 'received' file not created by untoast")
- else:
- print(f"untoast failed: {result.stderr}")
- else:
- print("No data received from simulator")
-
- sock.close()
-
-
-if __name__ == "__main__":
- mode = input("Enter 'send' to send data or 'receive' to receive data: ").strip().lower()
- run_protocol(send_mode=(mode == "send"))
\ No newline at end of file
diff --git a/protocol_prototype/DryBox/wav/input.wav b/protocol_prototype/DryBox/wav/input.wav
index 576bfd8..da3e917 100644
Binary files a/protocol_prototype/DryBox/wav/input.wav and b/protocol_prototype/DryBox/wav/input.wav differ
diff --git a/protocol_prototype/DryBox/wav/input_original.wav b/protocol_prototype/DryBox/wav/input_original.wav
new file mode 100644
index 0000000..576bfd8
Binary files /dev/null and b/protocol_prototype/DryBox/wav/input_original.wav differ
diff --git a/protocol_prototype/DryBox/wav/test_codec_only.wav b/protocol_prototype/DryBox/wav/test_codec_only.wav
new file mode 100644
index 0000000..b5f4502
Binary files /dev/null and b/protocol_prototype/DryBox/wav/test_codec_only.wav differ
diff --git a/protocol_prototype/DryBox/wav/test_full_pipeline.wav b/protocol_prototype/DryBox/wav/test_full_pipeline.wav
new file mode 100644
index 0000000..b5f4502
Binary files /dev/null and b/protocol_prototype/DryBox/wav/test_full_pipeline.wav differ
diff --git a/protocol_prototype/Prototype/Protocol_Alpha_0/IcingProtocol.drawio b/protocol_prototype/Prototype/Protocol_Alpha_0/IcingProtocol.drawio
new file mode 100644
index 0000000..8f46988
--- /dev/null
+++ b/protocol_prototype/Prototype/Protocol_Alpha_0/IcingProtocol.drawio
@@ -0,0 +1,566 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/protocol_prototype/Prototype/Protocol_Alpha_0/VOICE_PROTOCOL_README.md b/protocol_prototype/Prototype/Protocol_Alpha_0/VOICE_PROTOCOL_README.md
new file mode 100644
index 0000000..885e798
--- /dev/null
+++ b/protocol_prototype/Prototype/Protocol_Alpha_0/VOICE_PROTOCOL_README.md
@@ -0,0 +1,119 @@
+# Voice-over-GSM Protocol Implementation
+
+This implementation provides encrypted voice communication over standard GSM voice channels without requiring CSD/HSCSD.
+
+## Architecture
+
+### 1. Voice Codec (`voice_codec.py`)
+- **Codec2Wrapper**: Simulates Codec2 compression
+ - Supports multiple bitrates (700-3200 bps)
+ - Default: 1200 bps for GSM robustness
+ - 40ms frames (48 bits/frame at 1200 bps)
+
+- **FSKModem**: 4-FSK modulation for voice channels
+ - Frequency band: 300-3400 Hz (GSM compatible)
+ - Symbol rate: 600 baud
+ - 4 frequencies: 600, 1200, 1800, 2400 Hz
+ - Preamble: 800 Hz for 100ms
+
+- **VoiceProtocol**: Integration layer
+ - Manages codec and modem
+ - Handles encryption with ChaCha20-CTR
+ - Frame-based processing
+
+### 2. Protocol Messages (`messages.py`)
+- **VoiceStart** (20 bytes): Initiates voice call
+ - Version, codec mode, FEC type
+ - Session ID (64 bits)
+ - Initial sequence number
+
+- **VoiceAck** (16 bytes): Accepts/rejects call
+ - Status (accept/reject)
+ - Negotiated codec and FEC
+
+- **VoiceEnd** (12 bytes): Terminates call
+ - Session ID for confirmation
+
+- **VoiceSync** (20 bytes): Synchronization
+ - Sequence number and timestamp
+ - For jitter buffer management
+
+### 3. Encryption (`encryption.py`)
+- **ChaCha20-CTR**: Stream cipher for voice
+ - No authentication overhead (HMAC per second)
+ - 12-byte nonce with frame counter
+ - Uses HKDF-derived key from main protocol
+
+### 4. Protocol Integration (`protocol.py`)
+- Voice session management
+- Message handlers for all voice messages
+- Methods:
+ - `start_voice_call()`: Initiate call
+ - `accept_voice_call()`: Accept incoming
+ - `end_voice_call()`: Terminate
+ - `send_voice_audio()`: Process audio
+
+## Usage Example
+
+```python
+# After key exchange is complete
+alice.start_voice_call(codec_mode=5, fec_type=0)
+
+# Bob automatically accepts if in auto mode
+# Or manually: bob.accept_voice_call(session_id, codec_mode, fec_type)
+
+# Send audio
+audio_samples = generate_audio() # 8kHz, 16-bit PCM
+alice.send_voice_audio(audio_samples)
+
+# End call
+alice.end_voice_call()
+```
+
+## Key Features
+
+1. **Codec2 @ 1200 bps**
+ - Optimal for GSM vocoder survival
+ - Intelligible but "robotic" quality
+
+2. **4-FSK Modulation**
+ - Survives GSM/AMR/EVS vocoders
+ - 2400 baud with FEC
+
+3. **ChaCha20-CTR Encryption**
+ - Low latency stream cipher
+ - Frame-based IV management
+
+4. **Forward Error Correction**
+ - Repetition code (3x)
+ - Future: Convolutional or LDPC
+
+5. **No Special Requirements**
+ - Works over standard voice calls
+ - Compatible with any phone
+ - Software-only solution
+
+## Testing
+
+Run the test scripts:
+- `test_voice_simple.py`: Basic voice call setup
+- `test_voice_protocol.py`: Full test with audio simulation (requires numpy)
+
+## Implementation Notes
+
+1. Message disambiguation: VoiceStart sets high bit in flags field to distinguish from VoiceSync (both 20 bytes)
+
+2. The actual Codec2 library would need to be integrated for production use
+
+3. FEC implementation is simplified (repetition code) - production would use convolutional codes
+
+4. Audio I/O integration needed for real voice calls
+
+5. Jitter buffer and timing recovery needed for production
+
+## Security Considerations
+
+- Voice frames use ChaCha20-CTR without per-frame authentication
+- HMAC computed over 1-second blocks for efficiency
+- Session binding through encrypted session ID
+- PFS maintained through main protocol key rotation
\ No newline at end of file
diff --git a/protocol_prototype/Prototype/Protocol_Alpha_0/auto_mode.py b/protocol_prototype/Prototype/Protocol_Alpha_0/auto_mode.py
new file mode 100644
index 0000000..690ba9c
--- /dev/null
+++ b/protocol_prototype/Prototype/Protocol_Alpha_0/auto_mode.py
@@ -0,0 +1,430 @@
+import time
+import threading
+import queue
+from typing import Optional, Dict, Any, List, Callable, Tuple
+
+# ANSI colors for logging
+RED = "\033[91m"
+GREEN = "\033[92m"
+YELLOW = "\033[93m"
+BLUE = "\033[94m"
+RESET = "\033[0m"
+
+class AutoModeConfig:
+ """Configuration parameters for the automatic mode behavior."""
+ def __init__(self):
+ # Ping behavior
+ self.ping_response_accept = True # Whether to accept incoming pings
+ self.ping_auto_initiate = False # Whether to initiate pings when connected
+ self.ping_retry_count = 3 # Number of ping retries
+ self.ping_retry_delay = 5.0 # Seconds between ping retries
+ self.ping_timeout = 10.0 # Seconds to wait for ping response
+ self.preferred_cipher = 0 # 0=AES-GCM, 1=ChaCha20-Poly1305
+
+ # Handshake behavior
+ self.handshake_retry_count = 3 # Number of handshake retries
+ self.handshake_retry_delay = 5.0 # Seconds between handshake retries
+ self.handshake_timeout = 10.0 # Seconds to wait for handshake
+
+ # Messaging behavior
+ self.auto_message_enabled = False # Whether to auto-send messages
+ self.message_interval = 10.0 # Seconds between auto messages
+ self.message_content = "Hello, secure world!" # Default message
+
+ # General behavior
+ self.active_mode = False # If true, initiates protocol instead of waiting
+
+
+class AutoMode:
+ """
+ Manages automated behavior for the Icing protocol.
+ Handles automatic progression through the protocol stages:
+ 1. Connection setup
+ 2. Ping/discovery
+ 3. Key exchange
+ 4. Encrypted communication
+ """
+
+ def __init__(self, protocol_interface):
+ """
+ Initialize the AutoMode manager.
+
+ Args:
+ protocol_interface: An object implementing the required protocol methods
+ """
+ self.protocol = protocol_interface
+ self.config = AutoModeConfig()
+ self.active = False
+ self.state = "idle"
+
+ # Message queue for automated sending
+ self.message_queue = queue.Queue()
+
+ # Tracking variables
+ self.ping_attempts = 0
+ self.handshake_attempts = 0
+ self.last_action_time = 0
+ self.timer_tasks = [] # List of active timer tasks (for cleanup)
+
+ def start(self):
+ """Start the automatic mode."""
+ if self.active:
+ return
+
+ self.active = True
+ self.state = "idle"
+ self.ping_attempts = 0
+ self.handshake_attempts = 0
+ self.last_action_time = time.time()
+
+ self._log_info("Automatic mode started")
+
+ # Start in active mode if configured
+ if self.config.active_mode and self.protocol.connections:
+ self._start_ping_sequence()
+
+ def stop(self):
+ """Stop the automatic mode and clean up any pending tasks."""
+ if not self.active:
+ return
+
+ # Cancel any pending timers
+ for timer in self.timer_tasks:
+ if timer.is_alive():
+ timer.cancel()
+ self.timer_tasks = []
+
+ self.active = False
+ self.state = "idle"
+ self._log_info("Automatic mode stopped")
+
+ def handle_connection_established(self):
+ """Called when a new connection is established."""
+ if not self.active:
+ return
+
+ self._log_info("Connection established")
+
+ # If in active mode, start pinging
+ if self.config.active_mode:
+ self._start_ping_sequence()
+
+ def handle_ping_received(self, index: int):
+ """
+ Handle a received ping request.
+
+ Args:
+ index: Index of the ping request in the protocol's inbound message queue
+ """
+ if not self.active or not self._is_valid_message_index(index):
+ return
+
+ self._log_info(f"Ping request received (index={index})")
+
+ # Automatically respond to ping if configured to accept
+ if self.config.ping_response_accept:
+ self._log_info(f"Auto-responding to ping with accept={self.config.ping_response_accept}")
+ try:
+ # Schedule the response with a small delay to simulate real behavior
+ timer = threading.Timer(0.5, self._respond_to_ping, args=[index])
+ timer.daemon = True
+ timer.start()
+ self.timer_tasks.append(timer)
+ except Exception as e:
+ self._log_error(f"Failed to auto-respond to ping: {e}")
+
+ def handle_ping_response_received(self, accepted: bool):
+ """
+ Handle a received ping response.
+
+ Args:
+ accepted: Whether the ping was accepted
+ """
+ if not self.active:
+ return
+
+ self.ping_attempts = 0 # Reset ping attempts counter
+
+ if accepted:
+ self._log_info("Ping accepted! Proceeding with handshake")
+ # Send handshake if not already done
+ if self.state != "handshake_sent":
+ self._ensure_ephemeral_keys()
+ self._start_handshake_sequence()
+ else:
+ self._log_info("Ping rejected by peer. Stopping auto-protocol sequence.")
+ self.state = "idle"
+
+ def handle_handshake_received(self, index: int):
+ """
+ Handle a received handshake.
+
+ Args:
+ index: Index of the handshake in the protocol's inbound message queue
+ """
+ if not self.active or not self._is_valid_message_index(index):
+ return
+
+ self._log_info(f"Handshake received (index={index})")
+
+ try:
+ # Ensure we have ephemeral keys
+ self._ensure_ephemeral_keys()
+
+ # Process the handshake (compute ECDH)
+ self.protocol.generate_ecdhe(index)
+
+ # Derive HKDF key
+ self.protocol.derive_hkdf()
+
+ # If we haven't sent our handshake yet, send it
+ if self.state != "handshake_sent":
+ timer = threading.Timer(0.5, self.protocol.send_handshake)
+ timer.daemon = True
+ timer.start()
+ self.timer_tasks.append(timer)
+ self.state = "handshake_sent"
+ else:
+ self.state = "key_exchange_complete"
+
+ # Start sending queued messages if auto messaging is enabled
+ if self.config.auto_message_enabled:
+ self._start_message_sequence()
+
+ except Exception as e:
+ self._log_error(f"Failed to process handshake: {e}")
+
+ def handle_encrypted_received(self, index: int):
+ """
+ Handle a received encrypted message.
+
+ Args:
+ index: Index of the encrypted message in the protocol's inbound message queue
+ """
+ if not self.active or not self._is_valid_message_index(index):
+ return
+
+ # Try to decrypt automatically
+ try:
+ plaintext = self.protocol.decrypt_received_message(index)
+ self._log_info(f"Auto-decrypted message: {plaintext}")
+ except Exception as e:
+ self._log_error(f"Failed to auto-decrypt message: {e}")
+
+ def queue_message(self, message: str):
+ """
+ Add a message to the auto-send queue.
+
+ Args:
+ message: Message text to send
+ """
+ self.message_queue.put(message)
+ self._log_info(f"Message queued for sending: {message}")
+
+ # If we're in the right state, start sending messages
+ if self.active and self.state == "key_exchange_complete" and self.config.auto_message_enabled:
+ self._process_message_queue()
+
+ def _start_ping_sequence(self):
+ """Start the ping sequence to discover the peer."""
+ if self.ping_attempts >= self.config.ping_retry_count:
+ self._log_warning(f"Maximum ping attempts ({self.config.ping_retry_count}) reached")
+ self.state = "idle"
+ return
+
+ self.state = "pinging"
+ self.ping_attempts += 1
+
+ self._log_info(f"Sending ping request (attempt {self.ping_attempts}/{self.config.ping_retry_count})")
+ try:
+ self.protocol.send_ping_request(self.config.preferred_cipher)
+ self.last_action_time = time.time()
+
+ # Schedule next ping attempt if needed
+ timer = threading.Timer(
+ self.config.ping_retry_delay,
+ self._check_ping_response
+ )
+ timer.daemon = True
+ timer.start()
+ self.timer_tasks.append(timer)
+
+ except Exception as e:
+ self._log_error(f"Failed to send ping: {e}")
+
+ def _check_ping_response(self):
+ """Check if we got a ping response, retry if not."""
+ if not self.active or self.state != "pinging":
+ return
+
+ # If we've waited long enough for a response, retry
+ if time.time() - self.last_action_time >= self.config.ping_timeout:
+ self._log_warning("No ping response received, retrying")
+ self._start_ping_sequence()
+
+ def _respond_to_ping(self, index: int):
+ """
+ Respond to a ping request.
+
+ Args:
+ index: Index of the ping request in the inbound messages
+ """
+ if not self.active or not self._is_valid_message_index(index):
+ return
+
+ try:
+ answer = 1 if self.config.ping_response_accept else 0
+ self.protocol.respond_to_ping(index, answer)
+
+ if answer == 1:
+ # If we accepted, we should expect a handshake
+ self.state = "accepted_ping"
+ self._ensure_ephemeral_keys()
+
+ # Set a timer to send our handshake if we don't receive one
+ timer = threading.Timer(
+ self.config.handshake_timeout,
+ self._check_handshake_received
+ )
+ timer.daemon = True
+ timer.start()
+ self.timer_tasks.append(timer)
+ self.last_action_time = time.time()
+
+ except Exception as e:
+ self._log_error(f"Failed to respond to ping: {e}")
+
+ def _check_handshake_received(self):
+ """Check if we've received a handshake after accepting a ping."""
+ if not self.active or self.state != "accepted_ping":
+ return
+
+ # If we've waited long enough and haven't received a handshake, initiate one
+ if time.time() - self.last_action_time >= self.config.handshake_timeout:
+ self._log_warning("No handshake received after accepting ping, initiating handshake")
+ self._start_handshake_sequence()
+
+ def _start_handshake_sequence(self):
+ """Start the handshake sequence."""
+ if self.handshake_attempts >= self.config.handshake_retry_count:
+ self._log_warning(f"Maximum handshake attempts ({self.config.handshake_retry_count}) reached")
+ self.state = "idle"
+ return
+
+ self.state = "handshake_sent"
+ self.handshake_attempts += 1
+
+ self._log_info(f"Sending handshake (attempt {self.handshake_attempts}/{self.config.handshake_retry_count})")
+ try:
+ self.protocol.send_handshake()
+ self.last_action_time = time.time()
+
+ # Schedule handshake retry check
+ timer = threading.Timer(
+ self.config.handshake_retry_delay,
+ self._check_handshake_response
+ )
+ timer.daemon = True
+ timer.start()
+ self.timer_tasks.append(timer)
+
+ except Exception as e:
+ self._log_error(f"Failed to send handshake: {e}")
+
+ def _check_handshake_response(self):
+ """Check if we've completed the key exchange, retry handshake if not."""
+ if not self.active or self.state != "handshake_sent":
+ return
+
+ # If we've waited long enough for a response, retry
+ if time.time() - self.last_action_time >= self.config.handshake_timeout:
+ self._log_warning("No handshake response received, retrying")
+ self._start_handshake_sequence()
+
+ def _start_message_sequence(self):
+ """Start the automated message sending sequence."""
+ if not self.config.auto_message_enabled:
+ return
+
+ self._log_info("Starting automated message sequence")
+
+ # Add the default message if queue is empty
+ if self.message_queue.empty():
+ self.message_queue.put(self.config.message_content)
+
+ # Start processing the queue
+ self._process_message_queue()
+
+ def _process_message_queue(self):
+ """Process messages in the queue and send them."""
+ if not self.active or self.state != "key_exchange_complete" or not self.config.auto_message_enabled:
+ return
+
+ if not self.message_queue.empty():
+ message = self.message_queue.get()
+ self._log_info(f"Sending queued message: {message}")
+
+ try:
+ self.protocol.send_encrypted_message(message)
+
+ # Schedule next message send
+ timer = threading.Timer(
+ self.config.message_interval,
+ self._process_message_queue
+ )
+ timer.daemon = True
+ timer.start()
+ self.timer_tasks.append(timer)
+
+ except Exception as e:
+ self._log_error(f"Failed to send queued message: {e}")
+ # Put the message back in the queue
+ self.message_queue.put(message)
+
+ def _ensure_ephemeral_keys(self):
+ """Ensure ephemeral keys are generated if needed."""
+ if not hasattr(self.protocol, 'ephemeral_pubkey') or self.protocol.ephemeral_pubkey is None:
+ self._log_info("Generating ephemeral keys")
+ self.protocol.generate_ephemeral_keys()
+
+ def _is_valid_message_index(self, index: int) -> bool:
+ """
+ Check if a message index is valid in the protocol's inbound_messages queue.
+
+ Args:
+ index: The index to check
+
+ Returns:
+ bool: True if the index is valid, False otherwise
+ """
+ if not hasattr(self.protocol, 'inbound_messages'):
+ self._log_error("Protocol has no inbound_messages attribute")
+ return False
+
+ if index < 0 or index >= len(self.protocol.inbound_messages):
+ self._log_error(f"Invalid message index: {index}")
+ return False
+
+ return True
+
+ # Helper methods for logging
+ def _log_info(self, message: str):
+ print(f"{BLUE}[AUTO]{RESET} {message}")
+ if hasattr(self, 'verbose_logging') and self.verbose_logging:
+ state_info = f"(state={self.state})"
+ if 'pinging' in self.state and hasattr(self, 'ping_attempts'):
+ state_info += f", attempts={self.ping_attempts}/{self.config.ping_retry_count}"
+ elif 'handshake' in self.state and hasattr(self, 'handshake_attempts'):
+ state_info += f", attempts={self.handshake_attempts}/{self.config.handshake_retry_count}"
+ print(f"{BLUE}[AUTO-DETAIL]{RESET} {state_info}")
+
+ def _log_warning(self, message: str):
+ print(f"{YELLOW}[AUTO-WARN]{RESET} {message}")
+ if hasattr(self, 'verbose_logging') and self.verbose_logging:
+ timer_info = f"Active timers: {len(self.timer_tasks)}"
+ print(f"{YELLOW}[AUTO-WARN-DETAIL]{RESET} {timer_info}")
+
+ def _log_error(self, message: str):
+ print(f"{RED}[AUTO-ERROR]{RESET} {message}")
+ if hasattr(self, 'verbose_logging') and self.verbose_logging:
+ print(f"{RED}[AUTO-ERROR-DETAIL]{RESET} Current state: {self.state}, Active: {self.active}")
\ No newline at end of file
diff --git a/protocol_prototype/Prototype/Protocol_Alpha_0/cli.py b/protocol_prototype/Prototype/Protocol_Alpha_0/cli.py
new file mode 100644
index 0000000..53c3f01
--- /dev/null
+++ b/protocol_prototype/Prototype/Protocol_Alpha_0/cli.py
@@ -0,0 +1,328 @@
+import sys
+import argparse
+import shlex
+from protocol import IcingProtocol
+
+RED = "\033[91m"
+GREEN = "\033[92m"
+YELLOW = "\033[93m"
+BLUE = "\033[94m"
+MAGENTA = "\033[95m"
+CYAN = "\033[96m"
+RESET = "\033[0m"
+
+def print_help():
+ """Display all available commands."""
+ print(f"\n{YELLOW}=== Available Commands ==={RESET}")
+ print(f"\n{CYAN}Basic Protocol Commands:{RESET}")
+ print(" help - Show this help message")
+ print(" peer_id - Set peer identity public key")
+ print(" connect - Connect to a peer at the specified port")
+ print(" show_state - Display current protocol state")
+ print(" exit - Exit the program")
+
+ print(f"\n{CYAN}Manual Protocol Operation:{RESET}")
+ print(" generate_ephemeral_keys - Generate ephemeral ECDH keys")
+ print(" send_ping [cipher] - Send PING request (cipher: 0=AES-GCM, 1=ChaCha20-Poly1305, default: 0)")
+ print(" respond_ping <0|1> - Respond to a PING (0=reject, 1=accept)")
+ print(" send_handshake - Send handshake with ephemeral keys")
+ print(" generate_ecdhe - Process handshake at specified index")
+ print(" derive_hkdf - Derive encryption key using HKDF")
+ print(" send_encrypted - Encrypt and send a message")
+ print(" decrypt - Decrypt received message at index")
+
+ print(f"\n{CYAN}Automatic Mode Commands:{RESET}")
+ print(" auto start - Start automatic mode")
+ print(" auto stop - Stop automatic mode")
+ print(" auto status - Show current auto mode status and configuration")
+ print(" auto config - Configure auto mode parameters")
+ print(" auto config list - Show all configurable parameters")
+ print(" auto message - Queue message for automatic sending")
+ print(" auto passive - Configure as passive peer (responds to pings but doesn't initiate)")
+ print(" auto active - Configure as active peer (initiates protocol)")
+ print(" auto log - Toggle detailed logging for auto mode")
+
+ print(f"\n{CYAN}Debugging Commands:{RESET}")
+ print(" debug_message - Display detailed information about a message in the queue")
+
+ print(f"\n{CYAN}Legacy Commands:{RESET}")
+ print(" auto_responder - Enable/disable legacy auto responder (deprecated)")
+
+
+def main():
+ protocol = IcingProtocol()
+
+ print(f"{YELLOW}\n======================================")
+ print(" Icing Protocol - Secure Communication ")
+ print("======================================\n" + RESET)
+ print(f"Listening on port: {protocol.local_port}")
+ print(f"Your identity public key (hex): {protocol.identity_pubkey.hex()}")
+ print_help()
+
+ while True:
+ try:
+ line = input(f"{MAGENTA}Cmd>{RESET} ").strip()
+ except EOFError:
+ break
+ if not line:
+ continue
+
+ parts = shlex.split(line) # Handle quoted arguments properly
+ cmd = parts[0].lower()
+
+ try:
+ # Basic commands
+ if cmd == "exit":
+ protocol.stop()
+ break
+
+ elif cmd == "help":
+ print_help()
+
+ elif cmd == "show_state":
+ protocol.show_state()
+
+ elif cmd == "peer_id":
+ if len(parts) != 2:
+ print(f"{RED}[ERROR]{RESET} Usage: peer_id ")
+ continue
+ try:
+ protocol.set_peer_identity(parts[1])
+ except ValueError as e:
+ print(f"{RED}[ERROR]{RESET} Invalid public key: {e}")
+
+ elif cmd == "connect":
+ if len(parts) != 2:
+ print(f"{RED}[ERROR]{RESET} Usage: connect ")
+ continue
+ try:
+ port = int(parts[1])
+ protocol.connect_to_peer(port)
+ except ValueError:
+ print(f"{RED}[ERROR]{RESET} Invalid port number.")
+ except Exception as e:
+ print(f"{RED}[ERROR]{RESET} Connection failed: {e}")
+
+ # Manual protocol operation
+ elif cmd == "generate_ephemeral_keys":
+ protocol.generate_ephemeral_keys()
+
+ elif cmd == "send_ping":
+ # Optional cipher parameter (0 = AES-GCM, 1 = ChaCha20-Poly1305)
+ cipher = 0 # Default to AES-GCM
+ if len(parts) >= 2:
+ try:
+ cipher = int(parts[1])
+ if cipher not in (0, 1):
+ print(f"{YELLOW}[WARNING]{RESET} Unsupported cipher code {cipher}. Using AES-GCM (0).")
+ cipher = 0
+ except ValueError:
+ print(f"{YELLOW}[WARNING]{RESET} Invalid cipher code. Using AES-GCM (0).")
+ protocol.send_ping_request(cipher)
+
+ elif cmd == "send_handshake":
+ protocol.send_handshake()
+
+ elif cmd == "respond_ping":
+ if len(parts) != 3:
+ print(f"{RED}[ERROR]{RESET} Usage: respond_ping <0|1>")
+ continue
+ try:
+ idx = int(parts[1])
+ answer = int(parts[2])
+ if answer not in (0, 1):
+ print(f"{RED}[ERROR]{RESET} Answer must be 0 (reject) or 1 (accept).")
+ continue
+ protocol.respond_to_ping(idx, answer)
+ except ValueError:
+ print(f"{RED}[ERROR]{RESET} Index and answer must be integers.")
+ except Exception as e:
+ print(f"{RED}[ERROR]{RESET} Failed to respond to ping: {e}")
+
+ elif cmd == "generate_ecdhe":
+ if len(parts) != 2:
+ print(f"{RED}[ERROR]{RESET} Usage: generate_ecdhe ")
+ continue
+ try:
+ idx = int(parts[1])
+ protocol.generate_ecdhe(idx)
+ except ValueError:
+ print(f"{RED}[ERROR]{RESET} Index must be an integer.")
+ except Exception as e:
+ print(f"{RED}[ERROR]{RESET} Failed to process handshake: {e}")
+
+ elif cmd == "derive_hkdf":
+ try:
+ protocol.derive_hkdf()
+ except Exception as e:
+ print(f"{RED}[ERROR]{RESET} Failed to derive HKDF key: {e}")
+
+ elif cmd == "send_encrypted":
+ if len(parts) < 2:
+ print(f"{RED}[ERROR]{RESET} Usage: send_encrypted ")
+ continue
+ plaintext = " ".join(parts[1:])
+ try:
+ protocol.send_encrypted_message(plaintext)
+ except Exception as e:
+ print(f"{RED}[ERROR]{RESET} Failed to send encrypted message: {e}")
+
+ elif cmd == "decrypt":
+ if len(parts) != 2:
+ print(f"{RED}[ERROR]{RESET} Usage: decrypt ")
+ continue
+ try:
+ idx = int(parts[1])
+ protocol.decrypt_received_message(idx)
+ except ValueError:
+ print(f"{RED}[ERROR]{RESET} Index must be an integer.")
+ except Exception as e:
+ print(f"{RED}[ERROR]{RESET} Failed to decrypt message: {e}")
+
+ # Debugging commands
+ elif cmd == "debug_message":
+ if len(parts) != 2:
+ print(f"{RED}[ERROR]{RESET} Usage: debug_message ")
+ continue
+ try:
+ idx = int(parts[1])
+ protocol.debug_message(idx)
+ except ValueError:
+ print(f"{RED}[ERROR]{RESET} Index must be an integer.")
+ except Exception as e:
+ print(f"{RED}[ERROR]{RESET} Failed to debug message: {e}")
+
+ # Automatic mode commands
+ elif cmd == "auto":
+ if len(parts) < 2:
+ print(f"{RED}[ERROR]{RESET} Usage: auto [options]")
+ print("Available commands: start, stop, status, config, message, passive, active")
+ continue
+
+ subcmd = parts[1].lower()
+
+ if subcmd == "start":
+ protocol.start_auto_mode()
+ print(f"{GREEN}[AUTO]{RESET} Automatic mode started")
+
+ elif subcmd == "stop":
+ protocol.stop_auto_mode()
+ print(f"{GREEN}[AUTO]{RESET} Automatic mode stopped")
+
+ elif subcmd == "status":
+ config = protocol.get_auto_mode_config()
+ print(f"{YELLOW}=== Auto Mode Status ==={RESET}")
+ print(f"Active: {protocol.auto_mode.active}")
+ print(f"State: {protocol.auto_mode.state}")
+ print(f"\n{YELLOW}--- Configuration ---{RESET}")
+ for key, value in vars(config).items():
+ print(f" {key}: {value}")
+
+ elif subcmd == "config":
+ if len(parts) < 3:
+ print(f"{RED}[ERROR]{RESET} Usage: auto config or auto config list")
+ continue
+
+ if parts[2].lower() == "list":
+ config = protocol.get_auto_mode_config()
+ print(f"{YELLOW}=== Auto Mode Configuration Parameters ==={RESET}")
+ for key, value in vars(config).items():
+ print(f" {key} ({type(value).__name__}): {value}")
+ continue
+
+ if len(parts) != 4:
+ print(f"{RED}[ERROR]{RESET} Usage: auto config ")
+ continue
+
+ param = parts[2]
+ value_str = parts[3]
+
+ # Convert the string value to the appropriate type
+ config = protocol.get_auto_mode_config()
+ if not hasattr(config, param):
+ print(f"{RED}[ERROR]{RESET} Unknown parameter: {param}")
+ print("Use 'auto config list' to see all available parameters")
+ continue
+
+ current_value = getattr(config, param)
+ try:
+ if isinstance(current_value, bool):
+ if value_str.lower() in ("true", "yes", "on", "1"):
+ value = True
+ elif value_str.lower() in ("false", "no", "off", "0"):
+ value = False
+ else:
+ raise ValueError(f"Boolean value must be true/false/yes/no/on/off/1/0")
+ elif isinstance(current_value, int):
+ value = int(value_str)
+ elif isinstance(current_value, float):
+ value = float(value_str)
+ elif isinstance(current_value, str):
+ value = value_str
+ else:
+ value = value_str # Default to string
+
+ protocol.configure_auto_mode(**{param: value})
+ print(f"{GREEN}[AUTO]{RESET} Set {param} = {value}")
+
+ except ValueError as e:
+ print(f"{RED}[ERROR]{RESET} Invalid value for {param}: {e}")
+
+ elif subcmd == "message":
+ if len(parts) < 3:
+ print(f"{RED}[ERROR]{RESET} Usage: auto message ")
+ continue
+
+ message = " ".join(parts[2:])
+ protocol.queue_auto_message(message)
+ print(f"{GREEN}[AUTO]{RESET} Message queued for sending: {message}")
+
+ elif subcmd == "passive":
+ # Configure as passive peer (responds but doesn't initiate)
+ protocol.configure_auto_mode(
+ ping_response_accept=True,
+ ping_auto_initiate=False,
+ active_mode=False
+ )
+ print(f"{GREEN}[AUTO]{RESET} Configured as passive peer")
+
+ elif subcmd == "active":
+ # Configure as active peer (initiates protocol)
+ protocol.configure_auto_mode(
+ ping_response_accept=True,
+ ping_auto_initiate=True,
+ active_mode=True
+ )
+ print(f"{GREEN}[AUTO]{RESET} Configured as active peer")
+
+ else:
+ print(f"{RED}[ERROR]{RESET} Unknown auto mode command: {subcmd}")
+ print("Available commands: start, stop, status, config, message, passive, active")
+
+ # Legacy commands
+ elif cmd == "auto_responder":
+ if len(parts) != 2:
+ print(f"{RED}[ERROR]{RESET} Usage: auto_responder ")
+ continue
+ val = parts[1].lower()
+ if val not in ("on", "off"):
+ print(f"{RED}[ERROR]{RESET} Value must be 'on' or 'off'.")
+ continue
+ protocol.enable_auto_responder(val == "on")
+ print(f"{YELLOW}[WARNING]{RESET} Using legacy auto responder. Consider using 'auto' commands instead.")
+
+ else:
+ print(f"{RED}[ERROR]{RESET} Unknown command: {cmd}")
+ print("Type 'help' for a list of available commands.")
+
+ except Exception as e:
+ print(f"{RED}[ERROR]{RESET} Command failed: {e}")
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ print("\nExiting...")
+ except Exception as e:
+ print(f"{RED}[FATAL ERROR]{RESET} {e}")
+ sys.exit(1)
diff --git a/protocol_prototype/Prototype/Protocol_Alpha_0/crypto_utils.py b/protocol_prototype/Prototype/Protocol_Alpha_0/crypto_utils.py
new file mode 100644
index 0000000..8c2e110
--- /dev/null
+++ b/protocol_prototype/Prototype/Protocol_Alpha_0/crypto_utils.py
@@ -0,0 +1,165 @@
+import os
+from typing import Tuple
+from cryptography.exceptions import InvalidSignature
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import ec, utils
+from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature
+
+def generate_identity_keys() -> Tuple[ec.EllipticCurvePrivateKey, bytes]:
+ """
+ Generate an ECDSA (P-256) identity key pair.
+
+ Returns:
+ Tuple containing:
+ - private_key: EllipticCurvePrivateKey object
+ - public_key_bytes: Raw x||y format (64 bytes, 512 bits)
+ """
+ private_key = ec.generate_private_key(ec.SECP256R1())
+ public_numbers = private_key.public_key().public_numbers()
+
+ x_bytes = public_numbers.x.to_bytes(32, byteorder='big')
+ y_bytes = public_numbers.y.to_bytes(32, byteorder='big')
+ pubkey_bytes = x_bytes + y_bytes # 64 bytes total
+
+ return private_key, pubkey_bytes
+
+
+def load_peer_identity_key(pubkey_bytes: bytes) -> ec.EllipticCurvePublicKey:
+ """
+ Convert a raw public key (64 bytes, x||y format) to a cryptography public key object.
+
+ Args:
+ pubkey_bytes: Raw 64-byte public key (x||y format)
+
+ Returns:
+ EllipticCurvePublicKey object
+
+ Raises:
+ ValueError: If the pubkey_bytes is not exactly 64 bytes
+ """
+ if len(pubkey_bytes) != 64:
+ raise ValueError("Peer identity pubkey must be exactly 64 bytes (x||y).")
+
+ x_int = int.from_bytes(pubkey_bytes[:32], byteorder='big')
+ y_int = int.from_bytes(pubkey_bytes[32:], byteorder='big')
+
+ public_numbers = ec.EllipticCurvePublicNumbers(x_int, y_int, ec.SECP256R1())
+ return public_numbers.public_key()
+
+
+def sign_data(private_key: ec.EllipticCurvePrivateKey, data: bytes) -> bytes:
+ """
+ Sign data with ECDSA using a P-256 private key.
+
+ Args:
+ private_key: EllipticCurvePrivateKey for signing
+ data: Bytes to sign
+
+ Returns:
+ DER-encoded signature (variable length, up to ~70-72 bytes)
+ """
+ signature = private_key.sign(data, ec.ECDSA(hashes.SHA256()))
+ return signature
+
+
+def verify_signature(public_key: ec.EllipticCurvePublicKey, signature: bytes, data: bytes) -> bool:
+ """
+ Verify a DER-encoded ECDSA signature.
+
+ Args:
+ public_key: EllipticCurvePublicKey for verification
+ signature: DER-encoded signature
+ data: Original signed data
+
+ Returns:
+ True if signature is valid, False otherwise
+ """
+ try:
+ public_key.verify(signature, data, ec.ECDSA(hashes.SHA256()))
+ return True
+ except InvalidSignature:
+ return False
+
+
+def get_ephemeral_keypair() -> Tuple[ec.EllipticCurvePrivateKey, bytes]:
+ """
+ Generate an ephemeral ECDH key pair (P-256).
+
+ Returns:
+ Tuple containing:
+ - private_key: EllipticCurvePrivateKey object
+ - pubkey_bytes: Raw x||y format (64 bytes, 512 bits)
+ """
+ private_key = ec.generate_private_key(ec.SECP256R1())
+ numbers = private_key.public_key().public_numbers()
+
+ x_bytes = numbers.x.to_bytes(32, 'big')
+ y_bytes = numbers.y.to_bytes(32, 'big')
+
+ return private_key, x_bytes + y_bytes # 64 bytes total
+
+
+def compute_ecdh_shared_key(private_key: ec.EllipticCurvePrivateKey, peer_pubkey_bytes: bytes) -> bytes:
+ """
+ Compute a shared secret using ECDH.
+
+ Args:
+ private_key: Local ECDH private key
+ peer_pubkey_bytes: Peer's ephemeral public key (64 bytes, raw x||y format)
+
+ Returns:
+ Shared secret bytes
+
+ Raises:
+ ValueError: If peer_pubkey_bytes is not 64 bytes
+ """
+ if len(peer_pubkey_bytes) != 64:
+ raise ValueError("Peer public key must be 64 bytes (x||y format)")
+
+ x_int = int.from_bytes(peer_pubkey_bytes[:32], 'big')
+ y_int = int.from_bytes(peer_pubkey_bytes[32:], 'big')
+
+ # Create public key object from raw components
+ peer_public_numbers = ec.EllipticCurvePublicNumbers(x_int, y_int, ec.SECP256R1())
+ peer_public_key = peer_public_numbers.public_key()
+
+ # Perform key exchange
+ shared_key = private_key.exchange(ec.ECDH(), peer_public_key)
+ return shared_key
+
+
+def der_to_raw(der_sig: bytes) -> bytes:
+ """
+ Convert a DER-encoded ECDSA signature to a raw 64-byte signature (r||s).
+
+ Args:
+ der_sig: DER-encoded signature
+
+ Returns:
+ Raw 64-byte signature (r||s format), with each component padded to 32 bytes
+ """
+ r, s = decode_dss_signature(der_sig)
+ r_bytes = r.to_bytes(32, byteorder='big')
+ s_bytes = s.to_bytes(32, byteorder='big')
+ return r_bytes + s_bytes
+
+
+def raw_signature_to_der(raw_sig: bytes) -> bytes:
+ """
+ Convert a raw signature (64 bytes, concatenated r||s) to DER-encoded signature.
+
+ Args:
+ raw_sig: Raw 64-byte signature (r||s format)
+
+ Returns:
+ DER-encoded signature
+
+ Raises:
+ ValueError: If raw_sig is not 64 bytes
+ """
+ if len(raw_sig) != 64:
+ raise ValueError("Raw signature must be 64 bytes (r||s).")
+
+ r = int.from_bytes(raw_sig[:32], 'big')
+ s = int.from_bytes(raw_sig[32:], 'big')
+ return encode_dss_signature(r, s)
diff --git a/protocol_prototype/Prototype/Protocol_Alpha_0/encryption.py b/protocol_prototype/Prototype/Protocol_Alpha_0/encryption.py
new file mode 100644
index 0000000..87a6b32
--- /dev/null
+++ b/protocol_prototype/Prototype/Protocol_Alpha_0/encryption.py
@@ -0,0 +1,307 @@
+import os
+import struct
+from typing import Optional, Tuple
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305
+
+class MessageHeader:
+ """
+ Header of an encrypted message (18 bytes total):
+
+ Clear Text Section (4 bytes):
+ - flag: 16 bits (0xBEEF by default)
+ - data_len: 16 bits (length of encrypted payload excluding tag)
+
+ Associated Data (14 bytes):
+ - retry: 8 bits (retry counter)
+ - connection_status: 4 bits (e.g., CRC required) + 4 bits padding
+ - iv/messageID: 96 bits (12 bytes)
+ """
+ def __init__(self, flag: int, data_len: int, retry: int, connection_status: int, iv: bytes):
+ if not (0 <= flag < 65536):
+ raise ValueError("Flag must fit in 16 bits (0..65535)")
+ if not (0 <= data_len < 65536):
+ raise ValueError("Data length must fit in 16 bits (0..65535)")
+ if not (0 <= retry < 256):
+ raise ValueError("Retry must fit in 8 bits (0..255)")
+ if not (0 <= connection_status < 16):
+ raise ValueError("Connection status must fit in 4 bits (0..15)")
+ if len(iv) != 12:
+ raise ValueError("IV must be 12 bytes (96 bits)")
+
+ self.flag = flag # 16 bits
+ self.data_len = data_len # 16 bits
+ self.retry = retry # 8 bits
+ self.connection_status = connection_status # 4 bits
+ self.iv = iv # 96 bits (12 bytes)
+
+ def pack(self) -> bytes:
+ """Pack header into 18 bytes."""
+ # Pack flag and data_len (4 bytes)
+ header = struct.pack('>H H', self.flag, self.data_len)
+
+ # Pack retry and connection_status (2 bytes)
+ # connection_status in high 4 bits of second byte, 4 bits padding as zero
+ ad_byte = (self.connection_status & 0x0F) << 4
+ ad_packed = struct.pack('>B B', self.retry, ad_byte)
+
+ # Append IV (12 bytes)
+ return header + ad_packed + self.iv
+
+ def get_associated_data(self) -> bytes:
+ """Get the associated data for AEAD encryption (retry, conn_status, iv)."""
+ # Pack retry and connection_status
+ ad_byte = (self.connection_status & 0x0F) << 4
+ ad_packed = struct.pack('>B B', self.retry, ad_byte)
+
+ # Append IV
+ return ad_packed + self.iv
+
+ @classmethod
+ def unpack(cls, data: bytes) -> 'MessageHeader':
+ """Unpack 18 bytes into a MessageHeader object."""
+ if len(data) < 18:
+ raise ValueError(f"Header data too short: {len(data)} bytes, expected 18")
+
+ flag, data_len = struct.unpack('>H H', data[:4])
+ retry, ad_byte = struct.unpack('>B B', data[4:6])
+ connection_status = (ad_byte >> 4) & 0x0F
+ iv = data[6:18]
+
+ return cls(flag, data_len, retry, connection_status, iv)
+
+class EncryptedMessage:
+ """
+ Encrypted message packet format:
+
+ - Header (18 bytes):
+ * flag: 16 bits
+ * data_len: 16 bits
+ * retry: 8 bits
+ * connection_status: 4 bits (+ 4 bits padding)
+ * iv/messageID: 96 bits (12 bytes)
+
+ - Payload: variable length encrypted data
+
+ - Footer:
+ * Authentication tag: 128 bits (16 bytes)
+ * CRC32: 32 bits (4 bytes) - optional, based on connection_status
+ """
+ def __init__(self, plaintext: bytes, key: bytes, flag: int = 0xBEEF,
+ retry: int = 0, connection_status: int = 0, iv: bytes = None,
+ cipher_type: int = 0):
+ self.plaintext = plaintext
+ self.key = key
+ self.flag = flag
+ self.retry = retry
+ self.connection_status = connection_status
+ self.iv = iv or generate_iv(initial=True)
+ self.cipher_type = cipher_type # 0 = AES-256-GCM, 1 = ChaCha20-Poly1305
+
+ # Will be set after encryption
+ self.ciphertext = None
+ self.tag = None
+ self.header = None
+
+ def encrypt(self) -> bytes:
+ """Encrypt the plaintext and return the full encrypted message."""
+ # Create header with correct data_len (which will be set after encryption)
+ self.header = MessageHeader(
+ flag=self.flag,
+ data_len=0, # Will be updated after encryption
+ retry=self.retry,
+ connection_status=self.connection_status,
+ iv=self.iv
+ )
+
+ # Get associated data for AEAD
+ aad = self.header.get_associated_data()
+
+ # Encrypt using the appropriate cipher
+ if self.cipher_type == 0: # AES-256-GCM
+ cipher = AESGCM(self.key)
+ ciphertext_with_tag = cipher.encrypt(self.iv, self.plaintext, aad)
+ elif self.cipher_type == 1: # ChaCha20-Poly1305
+ cipher = ChaCha20Poly1305(self.key)
+ ciphertext_with_tag = cipher.encrypt(self.iv, self.plaintext, aad)
+ else:
+ raise ValueError(f"Unsupported cipher type: {self.cipher_type}")
+
+ # Extract ciphertext and tag
+ self.tag = ciphertext_with_tag[-16:]
+ self.ciphertext = ciphertext_with_tag[:-16]
+
+ # Update header with actual data length
+ self.header.data_len = len(self.ciphertext)
+
+ # Pack everything together
+ packed_header = self.header.pack()
+
+ # Check if CRC is required (based on connection_status)
+ if self.connection_status & 0x01: # Lowest bit indicates CRC required
+ import zlib
+ # Compute CRC32 of header + ciphertext + tag
+ crc = zlib.crc32(packed_header + self.ciphertext + self.tag) & 0xffffffff
+ crc_bytes = struct.pack('>I', crc)
+ return packed_header + self.ciphertext + self.tag + crc_bytes
+ else:
+ return packed_header + self.ciphertext + self.tag
+
+ @classmethod
+ def decrypt(cls, data: bytes, key: bytes, cipher_type: int = 0) -> Tuple[bytes, MessageHeader]:
+ """
+ Decrypt an encrypted message and return the plaintext and header.
+
+ Args:
+ data: The full encrypted message
+ key: The encryption key
+ cipher_type: 0 for AES-256-GCM, 1 for ChaCha20-Poly1305
+
+ Returns:
+ Tuple of (plaintext, header)
+ """
+ if len(data) < 18 + 16: # Header + minimum tag size
+ raise ValueError("Message too short")
+
+ # Extract header
+ header_bytes = data[:18]
+ header = MessageHeader.unpack(header_bytes)
+
+ # Get ciphertext and tag
+ data_len = header.data_len
+ ciphertext_start = 18
+ ciphertext_end = ciphertext_start + data_len
+
+ if ciphertext_end + 16 > len(data):
+ raise ValueError("Message length does not match header's data_len")
+
+ ciphertext = data[ciphertext_start:ciphertext_end]
+ tag = data[ciphertext_end:ciphertext_end + 16]
+
+ # Get associated data for AEAD
+ aad = header.get_associated_data()
+
+ # Combine ciphertext and tag for decryption
+ ciphertext_with_tag = ciphertext + tag
+
+ # Decrypt using the appropriate cipher
+ try:
+ if cipher_type == 0: # AES-256-GCM
+ cipher = AESGCM(key)
+ plaintext = cipher.decrypt(header.iv, ciphertext_with_tag, aad)
+ elif cipher_type == 1: # ChaCha20-Poly1305
+ cipher = ChaCha20Poly1305(key)
+ plaintext = cipher.decrypt(header.iv, ciphertext_with_tag, aad)
+ else:
+ raise ValueError(f"Unsupported cipher type: {cipher_type}")
+
+ return plaintext, header
+ except Exception as e:
+ raise ValueError(f"Decryption failed: {e}")
+
+def generate_iv(initial: bool = False, previous_iv: bytes = None) -> bytes:
+ """
+ Generate a 96-bit IV (12 bytes).
+
+ Args:
+ initial: If True, return a random IV
+ previous_iv: The previous IV to increment
+
+ Returns:
+ A new IV
+ """
+ if initial or previous_iv is None:
+ return os.urandom(12) # 96 bits
+ else:
+ # Increment the previous IV by 1 modulo 2^96
+ iv_int = int.from_bytes(previous_iv, 'big')
+ iv_int = (iv_int + 1) % (1 << 96)
+ return iv_int.to_bytes(12, 'big')
+
+# Convenience functions to match original API
+def encrypt_message(plaintext: bytes, key: bytes, flag: int = 0xBEEF,
+ retry: int = 0, connection_status: int = 0,
+ iv: bytes = None, cipher_type: int = 0) -> bytes:
+ """
+ Encrypt a message using the specified parameters.
+
+ Args:
+ plaintext: The data to encrypt
+ key: The encryption key (32 bytes for AES-256-GCM, 32 bytes for ChaCha20-Poly1305)
+ flag: 16-bit flag value (default: 0xBEEF)
+ retry: 8-bit retry counter
+ connection_status: 4-bit connection status
+ iv: Optional 96-bit IV (if None, a random one will be generated)
+ cipher_type: 0 for AES-256-GCM, 1 for ChaCha20-Poly1305
+
+ Returns:
+ The full encrypted message
+ """
+ message = EncryptedMessage(
+ plaintext=plaintext,
+ key=key,
+ flag=flag,
+ retry=retry,
+ connection_status=connection_status,
+ iv=iv,
+ cipher_type=cipher_type
+ )
+ return message.encrypt()
+
+def decrypt_message(message: bytes, key: bytes, cipher_type: int = 0) -> bytes:
+ """
+ Decrypt a message.
+
+ Args:
+ message: The full encrypted message
+ key: The encryption key
+ cipher_type: 0 for AES-256-GCM, 1 for ChaCha20-Poly1305
+
+ Returns:
+ The decrypted plaintext
+ """
+ plaintext, _ = EncryptedMessage.decrypt(message, key, cipher_type)
+ return plaintext
+
+# ChaCha20-CTR functions for voice streaming (without authentication)
+def chacha20_encrypt(plaintext: bytes, key: bytes, nonce: bytes) -> bytes:
+ """
+ Encrypt plaintext using ChaCha20 in CTR mode (no authentication).
+
+ Args:
+ plaintext: Data to encrypt
+ key: 32-byte key
+ nonce: 16-byte nonce (for ChaCha20 in cryptography library)
+
+ Returns:
+ Ciphertext
+ """
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+ from cryptography.hazmat.backends import default_backend
+
+ if len(key) != 32:
+ raise ValueError("ChaCha20 key must be 32 bytes")
+ if len(nonce) != 16:
+ raise ValueError("ChaCha20 nonce must be 16 bytes")
+
+ cipher = Cipher(
+ algorithms.ChaCha20(key, nonce),
+ mode=None,
+ backend=default_backend()
+ )
+ encryptor = cipher.encryptor()
+ return encryptor.update(plaintext) + encryptor.finalize()
+
+def chacha20_decrypt(ciphertext: bytes, key: bytes, nonce: bytes) -> bytes:
+ """
+ Decrypt ciphertext using ChaCha20 in CTR mode (no authentication).
+
+ Args:
+ ciphertext: Data to decrypt
+ key: 32-byte key
+ nonce: 12-byte nonce
+
+ Returns:
+ Plaintext
+ """
+ # ChaCha20 is symmetrical - encryption and decryption are the same
+ return chacha20_encrypt(ciphertext, key, nonce)
diff --git a/protocol_prototype/Prototype/Protocol_Alpha_0/messages.py b/protocol_prototype/Prototype/Protocol_Alpha_0/messages.py
new file mode 100644
index 0000000..5521499
--- /dev/null
+++ b/protocol_prototype/Prototype/Protocol_Alpha_0/messages.py
@@ -0,0 +1,463 @@
+import os
+import struct
+import time
+import zlib
+import hashlib
+from typing import Tuple, Optional
+
+def crc32_of(data: bytes) -> int:
+ """
+ Compute CRC-32 of 'data'.
+ """
+ return zlib.crc32(data) & 0xffffffff
+
+
+# ---------------------------------------------------------------------------
+# PING REQUEST (new format)
+# Fields (in order):
+# - session_nonce: 129 bits (from the top 129 bits of 17 random bytes)
+# - version: 7 bits
+# - cipher: 4 bits (0 = AES-256-GCM, 1 = ChaCha20-poly1305; for now only 0 is used)
+# - CRC: 32 bits
+#
+# Total bits: 129 + 7 + 4 + 32 = 172 bits. We pack into 22 bytes (176 bits) with 4 spare bits.
+# ---------------------------------------------------------------------------
+class PingRequest:
+ """
+ PING REQUEST format (172 bits / 22 bytes):
+ - session_nonce: 129 bits (from top 129 bits of 17 random bytes)
+ - version: 7 bits
+ - cipher: 4 bits (0 = AES-256-GCM, 1 = ChaCha20-poly1305)
+ - CRC: 32 bits
+ """
+ def __init__(self, version: int, cipher: int, session_nonce: bytes = None):
+ if not (0 <= version < 128):
+ raise ValueError("Version must fit in 7 bits (0..127)")
+ if not (0 <= cipher < 16):
+ raise ValueError("Cipher must fit in 4 bits (0..15)")
+
+ self.version = version
+ self.cipher = cipher
+
+ # Generate session nonce if not provided
+ if session_nonce is None:
+ # Generate 17 random bytes
+ nonce_full = os.urandom(17)
+ # Use top 129 bits
+ nonce_int_full = int.from_bytes(nonce_full, 'big')
+ nonce_129_int = nonce_int_full >> 7 # drop lowest 7 bits
+ self.session_nonce = nonce_129_int.to_bytes(17, 'big')
+ else:
+ if len(session_nonce) != 17:
+ raise ValueError("Session nonce must be 17 bytes (136 bits)")
+ self.session_nonce = session_nonce
+
+ def serialize(self) -> bytes:
+ """Serialize the ping request into a 22-byte packet."""
+ # Convert session_nonce to integer (129 bits)
+ nonce_int = int.from_bytes(self.session_nonce, 'big')
+
+ # Pack fields: shift nonce left by 11 bits, add version and cipher
+ partial_int = (nonce_int << 11) | (self.version << 4) | (self.cipher & 0x0F)
+ # This creates 129+7+4 = 140 bits; pack into 18 bytes
+ partial_bytes = partial_int.to_bytes(18, 'big')
+
+ # Compute CRC over these 18 bytes
+ cval = crc32_of(partial_bytes)
+
+ # Combine partial data with 32-bit CRC
+ final_int = (int.from_bytes(partial_bytes, 'big') << 32) | cval
+ return final_int.to_bytes(22, 'big')
+
+ @classmethod
+ def deserialize(cls, data: bytes) -> Optional['PingRequest']:
+ """Deserialize a 22-byte packet into a PingRequest object."""
+ if len(data) != 22:
+ return None
+
+ # Extract 176-bit integer
+ final_int = int.from_bytes(data, 'big')
+
+ # Extract CRC and verify
+ crc_in = final_int & 0xffffffff
+ partial_int = final_int >> 32 # 140 bits
+ partial_bytes = partial_int.to_bytes(18, 'big')
+ crc_calc = crc32_of(partial_bytes)
+
+ if crc_calc != crc_in:
+ return None
+
+ # Extract fields
+ cipher = partial_int & 0x0F
+ version = (partial_int >> 4) & 0x7F
+ nonce_129_int = partial_int >> 11 # 129 bits
+ session_nonce = nonce_129_int.to_bytes(17, 'big')
+
+ return cls(version, cipher, session_nonce)
+
+
+
+# ---------------------------------------------------------------------------
+# PING RESPONSE (new format)
+# Fields:
+# - timestamp: 32 bits (we take the lower 32 bits of the time in ms)
+# - version: 7 bits
+# - cipher: 4 bits
+# - answer: 1 bit
+# - CRC: 32 bits
+#
+# Total bits: 32 + 7 + 4 + 1 + 32 = 76 bits; pack into 10 bytes (80 bits) with 4 spare bits.
+# ---------------------------------------------------------------------------
+class PingResponse:
+ """
+ PING RESPONSE format (76 bits / 10 bytes):
+ - timestamp: 32 bits (milliseconds since epoch, lower 32 bits)
+ - version: 7 bits
+ - cipher: 4 bits
+ - answer: 1 bit (0 = no, 1 = yes)
+ - CRC: 32 bits
+ """
+ def __init__(self, version: int, cipher: int, answer: int, timestamp: int = None):
+ if not (0 <= version < 128):
+ raise ValueError("Version must fit in 7 bits")
+ if not (0 <= cipher < 16):
+ raise ValueError("Cipher must fit in 4 bits")
+ if answer not in (0, 1):
+ raise ValueError("Answer must be 0 or 1")
+
+ self.version = version
+ self.cipher = cipher
+ self.answer = answer
+ self.timestamp = timestamp or (int(time.time() * 1000) & 0xffffffff)
+
+ def serialize(self) -> bytes:
+ """Serialize the ping response into a 10-byte packet."""
+ # Pack timestamp, version, cipher, answer: 32+7+4+1 = 44 bits
+ # Shift left by 4 to put spare bits at the end
+ partial_val = (self.timestamp << (7+4+1)) | (self.version << (4+1)) | (self.cipher << 1) | self.answer
+ partial_val_shifted = partial_val << 4 # Add 4 spare bits at the end
+ partial_bytes = partial_val_shifted.to_bytes(6, 'big') # 6 bytes = 48 bits
+
+ # Compute CRC
+ cval = crc32_of(partial_bytes)
+
+ # Combine with CRC
+ final_val = (int.from_bytes(partial_bytes, 'big') << 32) | cval
+ return final_val.to_bytes(10, 'big')
+
+ @classmethod
+ def deserialize(cls, data: bytes) -> Optional['PingResponse']:
+ """Deserialize a 10-byte packet into a PingResponse object."""
+ if len(data) != 10:
+ return None
+
+ # Extract 80-bit integer
+ final_int = int.from_bytes(data, 'big')
+
+ # Extract CRC and verify
+ crc_in = final_int & 0xffffffff
+ partial_int = final_int >> 32 # 48 bits
+ partial_bytes = partial_int.to_bytes(6, 'big')
+ crc_calc = crc32_of(partial_bytes)
+
+ if crc_calc != crc_in:
+ return None
+
+ # Extract fields (discard 4 spare bits)
+ partial_int >>= 4 # now 44 bits
+ answer = partial_int & 0x01
+ cipher = (partial_int >> 1) & 0x0F
+ version = (partial_int >> (1+4)) & 0x7F
+ timestamp = partial_int >> (1+4+7)
+
+ return cls(version, cipher, answer, timestamp)
+
+
+# =============================================================================
+# 3) Handshake
+# - 32-bit timestamp
+# - 64-byte ephemeral pubkey (raw x||y = 512 bits)
+# - 64-byte ephemeral signature (raw r||s = 512 bits)
+# - 32-byte PFS hash (256 bits)
+# - 32-bit CRC
+# => total 4 + 64 + 64 + 32 + 4 = 168 bytes = 1344 bits
+# =============================================================================
+
+class Handshake:
+ """
+ HANDSHAKE format (1344 bits / 168 bytes):
+ - timestamp: 32 bits
+ - ephemeral_pubkey: 512 bits (64 bytes, raw x||y format)
+ - ephemeral_signature: 512 bits (64 bytes, raw r||s format)
+ - pfs_hash: 256 bits (32 bytes)
+ - CRC: 32 bits
+ """
+ def __init__(self, ephemeral_pubkey: bytes, ephemeral_signature: bytes, pfs_hash: bytes, timestamp: int = None):
+ if len(ephemeral_pubkey) != 64:
+ raise ValueError("ephemeral_pubkey must be 64 bytes (raw x||y)")
+ if len(ephemeral_signature) != 64:
+ raise ValueError("ephemeral_signature must be 64 bytes (raw r||s)")
+ if len(pfs_hash) != 32:
+ raise ValueError("pfs_hash must be 32 bytes")
+
+ self.ephemeral_pubkey = ephemeral_pubkey
+ self.ephemeral_signature = ephemeral_signature
+ self.pfs_hash = pfs_hash
+ self.timestamp = timestamp or (int(time.time() * 1000) & 0xffffffff)
+
+ def serialize(self) -> bytes:
+ """Serialize the handshake into a 168-byte packet."""
+ # Pack timestamp and other fields
+ partial = struct.pack("!I", self.timestamp) + self.ephemeral_pubkey + self.ephemeral_signature + self.pfs_hash
+
+ # Compute CRC
+ cval = crc32_of(partial)
+
+ # Append CRC
+ return partial + struct.pack("!I", cval)
+
+ @classmethod
+ def deserialize(cls, data: bytes) -> Optional['Handshake']:
+ """Deserialize a 168-byte packet into a Handshake object."""
+ if len(data) != 168:
+ return None
+
+ # Extract and verify CRC
+ partial = data[:-4]
+ crc_in = struct.unpack("!I", data[-4:])[0]
+ crc_calc = crc32_of(partial)
+
+ if crc_calc != crc_in:
+ return None
+
+ # Extract fields
+ timestamp = struct.unpack("!I", partial[:4])[0]
+ ephemeral_pubkey = partial[4:4+64]
+ ephemeral_signature = partial[68:68+64]
+ pfs_hash = partial[132:132+32]
+
+ return cls(ephemeral_pubkey, ephemeral_signature, pfs_hash, timestamp)
+
+
+# =============================================================================
+# 4) PFS Hash Helper
+# If no previous session, return 32 zero bytes
+# Otherwise, compute sha256(session_number || last_shared_secret).
+# =============================================================================
+
+def compute_pfs_hash(session_number: int, shared_secret_hex: str) -> bytes:
+ """
+ Compute the PFS hash field for handshake messages:
+ - If no previous session (session_number < 0), return 32 zero bytes
+ - Otherwise, compute sha256(session_number || shared_secret)
+ """
+ if session_number < 0:
+ return b"\x00" * 32
+
+ # Convert shared_secret_hex to raw bytes
+ secret_bytes = bytes.fromhex(shared_secret_hex)
+
+ # Pack session_number as 4 bytes
+ sn_bytes = struct.pack("!I", session_number)
+
+ # Compute hash
+ return hashlib.sha256(sn_bytes + secret_bytes).digest()
+
+
+# Helper function for CRC32 calculations
+def compute_crc32(data: bytes) -> int:
+ """Compute CRC32 of data (for consistency with crc32_of)."""
+ return zlib.crc32(data) & 0xffffffff
+
+
+# =============================================================================
+# Voice Protocol Messages
+# =============================================================================
+
+class VoiceStart:
+ """
+ Voice call initiation message (20 bytes).
+
+ Fields:
+ - version: 8 bits (protocol version)
+ - codec_mode: 8 bits (Codec2 mode)
+ - fec_type: 8 bits (0=repetition, 1=convolutional, 2=LDPC)
+ - flags: 8 bits (reserved for future use)
+ - session_id: 64 bits (unique voice session identifier)
+ - initial_sequence: 32 bits (starting sequence number)
+ - crc32: 32 bits
+ """
+
+ def __init__(self, version: int = 0, codec_mode: int = 5, fec_type: int = 0,
+ flags: int = 0, session_id: int = None, initial_sequence: int = 0):
+ self.version = version
+ self.codec_mode = codec_mode
+ self.fec_type = fec_type
+ self.flags = flags | 0x80 # Set high bit to distinguish from VoiceSync
+ self.session_id = session_id or int.from_bytes(os.urandom(8), 'big')
+ self.initial_sequence = initial_sequence
+
+ def serialize(self) -> bytes:
+ """Serialize to 20 bytes."""
+ # Pack all fields except CRC
+ data = struct.pack('>BBBBQII',
+ self.version,
+ self.codec_mode,
+ self.fec_type,
+ self.flags,
+ self.session_id,
+ self.initial_sequence,
+ 0 # CRC placeholder
+ )
+
+ # Calculate and append CRC
+ crc = compute_crc32(data[:-4])
+ return data[:-4] + struct.pack('>I', crc)
+
+ @classmethod
+ def deserialize(cls, data: bytes) -> Optional['VoiceStart']:
+ """Deserialize from bytes."""
+ if len(data) != 20:
+ return None
+
+ try:
+ version, codec_mode, fec_type, flags, session_id, initial_seq, crc = struct.unpack('>BBBBQII', data)
+
+ # Verify CRC
+ expected_crc = compute_crc32(data[:-4])
+ if crc != expected_crc:
+ return None
+
+ return cls(version, codec_mode, fec_type, flags, session_id, initial_seq)
+ except struct.error:
+ return None
+
+
+class VoiceAck:
+ """
+ Voice call acknowledgment message (16 bytes).
+
+ Fields:
+ - version: 8 bits
+ - status: 8 bits (0=reject, 1=accept)
+ - codec_mode: 8 bits (negotiated codec mode)
+ - fec_type: 8 bits (negotiated FEC type)
+ - session_id: 64 bits (echo of received session_id)
+ - crc32: 32 bits
+ """
+
+ def __init__(self, version: int = 0, status: int = 1, codec_mode: int = 5,
+ fec_type: int = 0, session_id: int = 0):
+ self.version = version
+ self.status = status
+ self.codec_mode = codec_mode
+ self.fec_type = fec_type
+ self.session_id = session_id
+
+ def serialize(self) -> bytes:
+ """Serialize to 16 bytes."""
+ data = struct.pack('>BBBBQI',
+ self.version,
+ self.status,
+ self.codec_mode,
+ self.fec_type,
+ self.session_id,
+ 0 # CRC placeholder
+ )
+
+ crc = compute_crc32(data[:-4])
+ return data[:-4] + struct.pack('>I', crc)
+
+ @classmethod
+ def deserialize(cls, data: bytes) -> Optional['VoiceAck']:
+ """Deserialize from bytes."""
+ if len(data) != 16:
+ return None
+
+ try:
+ version, status, codec_mode, fec_type, session_id, crc = struct.unpack('>BBBBQI', data)
+
+ expected_crc = compute_crc32(data[:-4])
+ if crc != expected_crc:
+ return None
+
+ return cls(version, status, codec_mode, fec_type, session_id)
+ except struct.error:
+ return None
+
+
+class VoiceEnd:
+ """
+ Voice call termination message (12 bytes).
+
+ Fields:
+ - session_id: 64 bits
+ - crc32: 32 bits
+ """
+
+ def __init__(self, session_id: int):
+ self.session_id = session_id
+
+ def serialize(self) -> bytes:
+ """Serialize to 12 bytes."""
+ data = struct.pack('>QI', self.session_id, 0)
+ crc = compute_crc32(data[:-4])
+ return data[:-4] + struct.pack('>I', crc)
+
+ @classmethod
+ def deserialize(cls, data: bytes) -> Optional['VoiceEnd']:
+ """Deserialize from bytes."""
+ if len(data) != 12:
+ return None
+
+ try:
+ session_id, crc = struct.unpack('>QI', data)
+
+ expected_crc = compute_crc32(data[:-4])
+ if crc != expected_crc:
+ return None
+
+ return cls(session_id)
+ except struct.error:
+ return None
+
+
+class VoiceSync:
+ """
+ Voice synchronization frame (20 bytes).
+ Used for maintaining sync and providing timing information.
+
+ Fields:
+ - session_id: 64 bits
+ - sequence: 32 bits
+ - timestamp: 32 bits (milliseconds since voice start)
+ - crc32: 32 bits
+ """
+
+ def __init__(self, session_id: int, sequence: int, timestamp: int):
+ self.session_id = session_id
+ self.sequence = sequence
+ self.timestamp = timestamp
+
+ def serialize(self) -> bytes:
+ """Serialize to 20 bytes."""
+ data = struct.pack('>QIII', self.session_id, self.sequence, self.timestamp, 0)
+ crc = compute_crc32(data[:-4])
+ return data[:-4] + struct.pack('>I', crc)
+
+ @classmethod
+ def deserialize(cls, data: bytes) -> Optional['VoiceSync']:
+ """Deserialize from bytes."""
+ if len(data) != 20:
+ return None
+
+ try:
+ session_id, sequence, timestamp, crc = struct.unpack('>QIII', data)
+
+ expected_crc = compute_crc32(data[:-4])
+ if crc != expected_crc:
+ return None
+
+ return cls(session_id, sequence, timestamp)
+ except struct.error:
+ return None
diff --git a/protocol_prototype/Prototype/Protocol_Alpha_0/protocol.py b/protocol_prototype/Prototype/Protocol_Alpha_0/protocol.py
new file mode 100644
index 0000000..28ed168
--- /dev/null
+++ b/protocol_prototype/Prototype/Protocol_Alpha_0/protocol.py
@@ -0,0 +1,1069 @@
+import random
+import os
+import time
+import threading
+from typing import List, Dict, Any, Optional, Tuple
+
+from crypto_utils import (
+ generate_identity_keys,
+ load_peer_identity_key,
+ sign_data,
+ verify_signature,
+ get_ephemeral_keypair,
+ compute_ecdh_shared_key,
+ der_to_raw,
+ raw_signature_to_der
+)
+from messages import (
+ PingRequest, PingResponse, Handshake,
+ compute_pfs_hash,
+ VoiceStart, VoiceAck, VoiceEnd, VoiceSync
+)
+import transmission
+from encryption import (
+ EncryptedMessage, MessageHeader,
+ generate_iv, encrypt_message, decrypt_message
+)
+from auto_mode import AutoMode, AutoModeConfig
+from voice_codec import VoiceProtocol
+
+# ANSI colors
+RED = "\033[91m"
+GREEN = "\033[92m"
+YELLOW = "\033[93m"
+BLUE = "\033[94m"
+RESET = "\033[0m"
+
+
+class IcingProtocol:
+ def __init__(self):
+ # Identity keys (each 512 bits when printed as hex of 64 bytes)
+ self.identity_privkey, self.identity_pubkey = generate_identity_keys()
+
+ # Peer identity for verifying ephemeral signatures
+ self.peer_identity_pubkey_obj = None
+ self.peer_identity_pubkey_bytes = None
+
+ # Ephemeral keys (our side)
+ self.ephemeral_privkey = None
+ self.ephemeral_pubkey = None
+
+ # Last computed shared secret (hex string)
+ self.shared_secret = None
+
+ # Derived HKDF key (hex string, 256 bits)
+ self.hkdf_key = None
+
+ # Negotiated cipher (0 = AES-256-GCM, 1 = ChaCha20-Poly1305)
+ self.cipher_type = 0
+
+ # For PFS: track per-peer session info (session number and last shared secret)
+ self.pfs_history: Dict[bytes, Tuple[int, str]] = {}
+
+ # Protocol flags
+ self.state = {
+ "ping_sent": False,
+ "ping_received": False,
+ "handshake_sent": False,
+ "handshake_received": False,
+ "key_exchange_complete": False
+ }
+
+ # Auto mode for automated protocol operation
+ self.auto_mode = AutoMode(self)
+
+ # Legacy auto-responder toggle (kept for backward compatibility)
+ self.auto_responder = False
+
+ # Voice protocol handler
+ self.voice_protocol = None # Will be initialized after key exchange
+ self.voice_session_active = False
+ self.voice_session_id = None
+
+ # Active connections list
+ self.connections = []
+
+ # Inbound messages (each message is a dict with keys: type, raw, parsed, connection)
+ self.inbound_messages: List[Dict[str, Any]] = []
+
+ # Store the session nonce (17 bytes but only 129 bits are valid) from first sent or received PING
+ self.session_nonce: bytes = None
+
+ # Last used IV for encrypted messages
+ self.last_iv: bytes = None
+
+ self.local_port = random.randint(30000, 40000)
+ self.server_listener = transmission.ServerListener(
+ host="127.0.0.1",
+ port=self.local_port,
+ on_new_connection=self.on_new_connection,
+ on_data_received=self.on_data_received
+ )
+ self.server_listener.start()
+
+ # -------------------------------------------------------------------------
+ # Transport callbacks
+ # -------------------------------------------------------------------------
+
+ def on_new_connection(self, conn: transmission.PeerConnection):
+ print(f"{GREEN}[IcingProtocol]{RESET} New incoming connection.")
+ self.connections.append(conn)
+
+ # Notify auto mode
+ self.auto_mode.handle_connection_established()
+
+ def on_data_received(self, conn: transmission.PeerConnection, data: bytes):
+ bits_count = len(data) * 8
+ print(
+ f"{GREEN}[RECV]{RESET} {bits_count} bits from peer: {data.hex()[:60]}{'...' if len(data.hex()) > 60 else ''}")
+
+ # PING REQUEST (22 bytes)
+ if len(data) == 22:
+ ping_request = PingRequest.deserialize(data)
+ if ping_request:
+ self.state["ping_received"] = True
+
+ # If received cipher is not supported, force to 0 (AES-256-GCM)
+ if ping_request.cipher != 0 and ping_request.cipher != 1:
+ print(f"{YELLOW}[NOTICE]{RESET} Received PING with unsupported cipher ({ping_request.cipher}); forcing cipher to 0 in response.")
+ ping_request.cipher = 0
+
+ # Store cipher type for future encrypted messages
+ self.cipher_type = ping_request.cipher
+
+ # Store session nonce if not already set
+ if self.session_nonce is None:
+ self.session_nonce = ping_request.session_nonce
+ print(f"{YELLOW}[NOTICE]{RESET} Stored session nonce from received PING.")
+
+ index = len(self.inbound_messages)
+ msg = {
+ "type": "PING_REQUEST",
+ "raw": data,
+ "parsed": ping_request,
+ "connection": conn
+ }
+ self.inbound_messages.append(msg)
+
+ # Handle in auto mode (if active)
+ self.auto_mode.handle_ping_received(index)
+
+ # Legacy auto-responder (for backward compatibility)
+ if self.auto_responder and not self.auto_mode.active:
+ timer = threading.Timer(2.0, self._auto_respond_ping, args=[index])
+ timer.daemon = True
+ timer.start()
+ return
+
+ # PING RESPONSE (10 bytes)
+ elif len(data) == 10:
+ ping_response = PingResponse.deserialize(data)
+ if ping_response:
+ # Store negotiated cipher type
+ self.cipher_type = ping_response.cipher
+
+ index = len(self.inbound_messages)
+ msg = {
+ "type": "PING_RESPONSE",
+ "raw": data,
+ "parsed": ping_response,
+ "connection": conn
+ }
+ self.inbound_messages.append(msg)
+
+ # Notify auto mode (if active)
+ self.auto_mode.handle_ping_response_received(ping_response.answer == 1)
+ return
+
+ # HANDSHAKE message (168 bytes)
+ elif len(data) == 168:
+ handshake = Handshake.deserialize(data)
+ if handshake:
+ self.state["handshake_received"] = True
+ index = len(self.inbound_messages)
+ msg = {
+ "type": "HANDSHAKE",
+ "raw": data,
+ "parsed": handshake,
+ "connection": conn
+ }
+ self.inbound_messages.append(msg)
+
+ # Notify auto mode (if active)
+ self.auto_mode.handle_handshake_received(index)
+
+ # Legacy auto-responder
+ if self.auto_responder and not self.auto_mode.active:
+ timer = threading.Timer(2.0, self._auto_respond_handshake, args=[index])
+ timer.daemon = True
+ timer.start()
+ return
+
+ # VOICE_START or VOICE_SYNC message (20 bytes)
+ elif len(data) == 20:
+ # Check fourth byte (flags field) to distinguish between messages
+ # VOICE_START has high bit set in flags (byte 3)
+ # VOICE_SYNC doesn't have this structure
+ if len(data) >= 4 and (data[3] & 0x80):
+ # Try VOICE_START first
+ voice_start = VoiceStart.deserialize(data)
+ if voice_start:
+ index = len(self.inbound_messages)
+ msg = {
+ "type": "VOICE_START",
+ "raw": data,
+ "parsed": voice_start,
+ "connection": conn
+ }
+ self.inbound_messages.append(msg)
+ print(f"{YELLOW}[NOTICE]{RESET} Received VOICE_START at index={index}.")
+
+ # Handle voice call initiation
+ self.handle_voice_start(index)
+ return
+
+ # Try VOICE_SYNC
+ voice_sync = VoiceSync.deserialize(data)
+ if voice_sync:
+ index = len(self.inbound_messages)
+ msg = {
+ "type": "VOICE_SYNC",
+ "raw": data,
+ "parsed": voice_sync,
+ "connection": conn
+ }
+ self.inbound_messages.append(msg)
+ print(f"{YELLOW}[NOTICE]{RESET} Received VOICE_SYNC at index={index}.")
+
+ # Handle voice synchronization
+ self.handle_voice_sync(index)
+ return
+
+ # VOICE_ACK message (16 bytes)
+ elif len(data) == 16:
+ # Try VOICE_ACK first, then fall back to PING_RESPONSE
+ voice_ack = VoiceAck.deserialize(data)
+ if voice_ack:
+ index = len(self.inbound_messages)
+ msg = {
+ "type": "VOICE_ACK",
+ "raw": data,
+ "parsed": voice_ack,
+ "connection": conn
+ }
+ self.inbound_messages.append(msg)
+ print(f"{YELLOW}[NOTICE]{RESET} Received VOICE_ACK at index={index}.")
+
+ # Handle voice call acknowledgment
+ self.handle_voice_ack(index)
+ return
+
+ # VOICE_END message (12 bytes)
+ elif len(data) == 12:
+ voice_end = VoiceEnd.deserialize(data)
+ if voice_end:
+ index = len(self.inbound_messages)
+ msg = {
+ "type": "VOICE_END",
+ "raw": data,
+ "parsed": voice_end,
+ "connection": conn
+ }
+ self.inbound_messages.append(msg)
+ print(f"{YELLOW}[NOTICE]{RESET} Received VOICE_END at index={index}.")
+
+ # Handle voice call termination
+ self.handle_voice_end(index)
+ return
+
+
+ # Check if the message might be an encrypted message (e.g. header of 18 bytes at start)
+ elif len(data) >= 18:
+ # Try to parse header
+ try:
+ header = MessageHeader.unpack(data[:18])
+ # If header unpacking is successful and data length matches header expectations
+ expected_len = 18 + header.data_len + 16 # Header + payload + tag
+
+ # Check if CRC is included
+ has_crc = (header.connection_status & 0x01) != 0
+ if has_crc:
+ expected_len += 4 # Add CRC32 length
+
+ if len(data) >= expected_len:
+ index = len(self.inbound_messages)
+ msg = {
+ "type": "ENCRYPTED_MESSAGE",
+ "raw": data,
+ "parsed": header,
+ "connection": conn
+ }
+ self.inbound_messages.append(msg)
+ print(f"{YELLOW}[NOTICE]{RESET} Stored inbound ENCRYPTED_MESSAGE at index={index}.")
+
+ # Notify auto mode
+ self.auto_mode.handle_encrypted_received(index)
+ return
+ except Exception as e:
+ print(f"{RED}[ERROR]{RESET} Failed to parse message header: {e}")
+
+ # Otherwise, unrecognized/malformed message.
+ index = len(self.inbound_messages)
+ msg = {
+ "type": "UNKNOWN",
+ "raw": data,
+ "parsed": None,
+ "connection": conn
+ }
+ self.inbound_messages.append(msg)
+ print(f"{RED}[WARNING]{RESET} Unrecognized or malformed message stored at index={index}.")
+
+
+ # -------------------------------------------------------------------------
+ # HKDF Derivation
+ # -------------------------------------------------------------------------
+
+ def derive_hkdf(self):
+ """
+ Derives a 256-bit key using HKDF.
+ Uses as input keying material (IKM) the shared secret from ECDH.
+ The salt is computed as SHA256(session_nonce || pfs_param), where:
+ - session_nonce is taken from self.session_nonce (17 bytes, 129 bits) or defaults to zeros.
+ - pfs_param is taken from the first inbound HANDSHAKE's pfs_hash field (32 bytes) or zeros.
+ """
+ if not self.shared_secret:
+ print(f"{RED}[ERROR]{RESET} No shared secret available; cannot derive HKDF key.")
+ return
+
+ # IKM: shared secret converted from hex to bytes.
+ ikm = bytes.fromhex(self.shared_secret)
+ # Use stored session_nonce if available; otherwise default to zeros.
+ session_nonce = self.session_nonce if self.session_nonce is not None else (b"\x00" * 17)
+
+ # For now, use a simpler approach: just use session_nonce for salt
+ # This ensures both peers derive the same key
+ # PFS is still maintained through the shared secret rotation
+ pfs_param = b"\x00" * 32 # Will use session_nonce only for salt
+
+ # Ensure both are bytes
+ if isinstance(session_nonce, str):
+ session_nonce = session_nonce.encode()
+ if isinstance(pfs_param, str):
+ pfs_param = pfs_param.encode()
+
+ from cryptography.hazmat.primitives import hashes
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+ hasher = hashes.Hash(hashes.SHA256())
+ hasher.update(session_nonce + pfs_param)
+ salt_value = hasher.finalize()
+
+ hkdf = HKDF(
+ algorithm=hashes.SHA256(),
+ length=32, # 256 bits
+ salt=salt_value,
+ info=b"",
+ )
+ derived_key = hkdf.derive(ikm)
+ self.hkdf_key = derived_key.hex()
+ self.state["key_exchange_complete"] = True
+ print(f"{GREEN}[HKDF]{RESET} Derived HKDF key: {self.hkdf_key}")
+ return True
+
+ # -------------------------------------------------------------------------
+ # Legacy Auto-responder helpers (kept for backward compatibility)
+ # -------------------------------------------------------------------------
+
+ def _auto_respond_ping(self, index: int):
+ """
+ Called by a Timer to respond automatically to a PING_REQUEST after 2s.
+ """
+ print(f"{BLUE}[AUTO]{RESET} Delayed responding to PING at index={index}")
+ self.respond_to_ping(index, answer=1) # Accept by default
+ self.show_state()
+
+ def _auto_respond_handshake(self, index: int):
+ """
+ Called by a Timer to handle inbound HANDSHAKE automatically.
+ 1) Generate ephemeral keys if not already set
+ 2) Compute ECDH with the inbound ephemeral pub (generate_ecdhe)
+ 3) Send our handshake back
+ 4) Show state
+ """
+ print(f"{BLUE}[AUTO]{RESET} Delayed ECDH process for HANDSHAKE at index={index}")
+
+ # 1) Generate ephemeral keys if we don't have them
+ if not self.ephemeral_privkey or not self.ephemeral_pubkey:
+ self.generate_ephemeral_keys()
+
+ # 2) Compute ECDH from inbound ephemeral pub
+ self.generate_ecdhe(index)
+
+ # 3) Send our handshake to the peer
+ self.send_handshake()
+
+ # 4) Show final state
+ self.show_state()
+
+ # -------------------------------------------------------------------------
+ # Public Methods for Auto Mode Management
+ # -------------------------------------------------------------------------
+
+ def start_auto_mode(self):
+ """Start the automatic protocol operation mode."""
+ self.auto_mode.start()
+
+ def stop_auto_mode(self):
+ """Stop the automatic protocol operation mode."""
+ self.auto_mode.stop()
+
+ def configure_auto_mode(self, **kwargs):
+ """
+ Configure the automatic mode parameters.
+
+ Args:
+ **kwargs: Configuration parameters to set. Supported parameters:
+ - ping_response_accept: bool, whether to accept incoming pings
+ - ping_auto_initiate: bool, whether to initiate pings on connection
+ - ping_retry_count: int, number of ping retries
+ - ping_retry_delay: float, seconds between ping retries
+ - ping_timeout: float, seconds to wait for ping response
+ - preferred_cipher: int, preferred cipher (0=AES-GCM, 1=ChaCha20-Poly1305)
+ - handshake_retry_count: int, number of handshake retries
+ - handshake_retry_delay: float, seconds between handshake retries
+ - handshake_timeout: float, seconds to wait for handshake
+ - auto_message_enabled: bool, whether to auto-send messages
+ - message_interval: float, seconds between auto messages
+ - message_content: str, default message content
+ - active_mode: bool, whether to actively initiate protocol
+ """
+ for key, value in kwargs.items():
+ if hasattr(self.auto_mode.config, key):
+ setattr(self.auto_mode.config, key, value)
+ print(f"{BLUE}[CONFIG]{RESET} Set auto mode {key} = {value}")
+ else:
+ print(f"{RED}[ERROR]{RESET} Unknown auto mode configuration parameter: {key}")
+
+ def get_auto_mode_config(self):
+ """Return the current auto mode configuration."""
+ return self.auto_mode.config
+
+ def queue_auto_message(self, message: str):
+ """
+ Add a message to the auto-send queue.
+
+ Args:
+ message: Message text to send
+ """
+ self.auto_mode.queue_message(message)
+
+ def toggle_auto_mode_logging(self):
+ """
+ Toggle detailed logging for auto mode.
+ When enabled, will show more information about state transitions and decision making.
+ """
+ if not hasattr(self.auto_mode, 'verbose_logging'):
+ self.auto_mode.verbose_logging = True
+ else:
+ self.auto_mode.verbose_logging = not self.auto_mode.verbose_logging
+
+ status = "enabled" if self.auto_mode.verbose_logging else "disabled"
+ print(f"{BLUE}[AUTO-LOG]{RESET} Detailed logging {status}")
+
+ def debug_message(self, index: int):
+ """
+ Debug a message in the inbound message queue.
+ Prints detailed information about the message.
+
+ Args:
+ index: The index of the message in the inbound_messages queue
+ """
+ if index < 0 or index >= len(self.inbound_messages):
+ print(f"{RED}[ERROR]{RESET} Invalid message index {index}")
+ return
+
+ msg = self.inbound_messages[index]
+ print(f"\n{YELLOW}=== Message Debug [{index}] ==={RESET}")
+ print(f"Type: {msg['type']}")
+ print(f"Length: {len(msg['raw'])} bytes = {len(msg['raw'])*8} bits")
+ print(f"Raw data: {msg['raw'].hex()}")
+
+ if msg['parsed'] is not None:
+ print(f"\n{YELLOW}--- Parsed Data ---{RESET}")
+ if msg['type'] == 'PING_REQUEST':
+ ping = msg['parsed']
+ print(f"Version: {ping.version}")
+ print(f"Cipher: {ping.cipher} ({'AES-256-GCM' if ping.cipher == 0 else 'ChaCha20-Poly1305' if ping.cipher == 1 else 'Unknown'})")
+ print(f"Session nonce: {ping.session_nonce.hex()}")
+ print(f"CRC32: {ping.crc32:08x}")
+
+ elif msg['type'] == 'PING_RESPONSE':
+ resp = msg['parsed']
+ print(f"Version: {resp.version}")
+ print(f"Cipher: {resp.cipher} ({'AES-256-GCM' if resp.cipher == 0 else 'ChaCha20-Poly1305' if resp.cipher == 1 else 'Unknown'})")
+ print(f"Answer: {resp.answer} ({'Accept' if resp.answer == 1 else 'Reject'})")
+ print(f"CRC32: {resp.crc32:08x}")
+
+ elif msg['type'] == 'HANDSHAKE':
+ hs = msg['parsed']
+ print(f"Ephemeral pubkey: {hs.ephemeral_pubkey.hex()[:16]}...")
+ print(f"Ephemeral signature: {hs.ephemeral_signature.hex()[:16]}...")
+ print(f"PFS hash: {hs.pfs_hash.hex()[:16]}...")
+ print(f"Timestamp: {hs.timestamp}")
+ print(f"CRC32: {hs.crc32:08x}")
+
+ elif msg['type'] == 'ENCRYPTED_MESSAGE':
+ header = msg['parsed']
+ print(f"Flag: 0x{header.flag:04x}")
+ print(f"Data length: {header.data_len} bytes")
+ print(f"Retry: {header.retry}")
+ print(f"Connection status: {header.connection_status} ({'CRC included' if header.connection_status & 0x01 else 'No CRC'})")
+ print(f"IV: {header.iv.hex()}")
+
+ # Calculate expected message size
+ expected_len = 18 + header.data_len + 16 # Header + payload + tag
+ if header.connection_status & 0x01:
+ expected_len += 4 # Add CRC
+
+ print(f"Expected total length: {expected_len} bytes")
+ print(f"Actual length: {len(msg['raw'])} bytes")
+
+ # If we have a key, try to decrypt
+ if self.hkdf_key:
+ print("\nAttempting decryption...")
+ try:
+ key = bytes.fromhex(self.hkdf_key)
+ plaintext = decrypt_message(msg['raw'], key, self.cipher_type)
+ print(f"Decrypted: {plaintext.decode('utf-8')}")
+ except Exception as e:
+ print(f"Decryption failed: {e}")
+ print()
+
+ # -------------------------------------------------------------------------
+ # Public Methods
+ # -------------------------------------------------------------------------
+
+ def connect_to_peer(self, port: int):
+ conn = transmission.connect_to_peer("127.0.0.1", port, self.on_data_received)
+ self.connections.append(conn)
+ print(f"{GREEN}[IcingProtocol]{RESET} Outgoing connection to port {port} established.")
+
+ # Notify auto mode
+ self.auto_mode.handle_connection_established()
+
+ def set_peer_identity(self, peer_pubkey_hex: str):
+ pubkey_bytes = bytes.fromhex(peer_pubkey_hex)
+ self.peer_identity_pubkey_obj = load_peer_identity_key(pubkey_bytes)
+ self.peer_identity_pubkey_bytes = pubkey_bytes
+ print(f"{GREEN}[IcingProtocol]{RESET} Stored peer identity pubkey (hex={peer_pubkey_hex[:16]}...).")
+
+ def generate_ephemeral_keys(self):
+ self.ephemeral_privkey, self.ephemeral_pubkey = get_ephemeral_keypair()
+ print(f"{GREEN}[IcingProtocol]{RESET} Generated ephemeral key pair: pubkey={self.ephemeral_pubkey.hex()[:16]}...")
+
+ # Send PING (session discovery and cipher negotiation)
+ def send_ping_request(self, cipher_type=0):
+ """
+ Send a ping request to the first connected peer.
+
+ Args:
+ cipher_type: Preferred cipher type (0 = AES-256-GCM, 1 = ChaCha20-Poly1305)
+ """
+ if not self.connections:
+ print(f"{RED}[ERROR]{RESET} No active connections.")
+ return False
+
+ # Validate cipher type
+ if cipher_type not in (0, 1):
+ print(f"{YELLOW}[WARNING]{RESET} Invalid cipher type {cipher_type}, defaulting to AES-256-GCM (0)")
+ cipher_type = 0
+
+ # Create ping request with specified cipher
+ ping_request = PingRequest(version=0, cipher=cipher_type)
+
+ # Store session nonce if not already set
+ if self.session_nonce is None:
+ self.session_nonce = ping_request.session_nonce
+ print(f"{YELLOW}[NOTICE]{RESET} Stored session nonce from sent PING.")
+
+ # Serialize and send
+ pkt = ping_request.serialize()
+ self._send_packet(self.connections[0], pkt, "PING_REQUEST")
+ self.state["ping_sent"] = True
+ return True
+
+ def send_handshake(self):
+ """
+ Build and send handshake:
+ - ephemeral_pubkey (64 bytes, raw x||y)
+ - ephemeral_signature (64 bytes, raw r||s)
+ - pfs_hash (32 bytes)
+ - timestamp (32 bits)
+ - CRC (32 bits)
+ """
+ if not self.connections:
+ print(f"{RED}[ERROR]{RESET} No active connections.")
+ return False
+ if not self.ephemeral_privkey or not self.ephemeral_pubkey:
+ print(f"{RED}[ERROR]{RESET} Ephemeral keys not generated.")
+ return False
+ if self.peer_identity_pubkey_bytes is None:
+ print(f"{RED}[ERROR]{RESET} Peer identity not set; needed for PFS tracking.")
+ return False
+
+ # 1) Sign ephemeral_pubkey using identity key
+ sig_der = sign_data(self.identity_privkey, self.ephemeral_pubkey)
+ # Convert DER signature to raw r||s format (64 bytes)
+ raw_signature = der_to_raw(sig_der)
+
+ # 2) Compute PFS hash
+ session_number, last_secret_hex = self.pfs_history.get(self.peer_identity_pubkey_bytes, (-1, ""))
+ pfs = compute_pfs_hash(session_number, last_secret_hex)
+
+ # 3) Create handshake object
+ handshake = Handshake(
+ ephemeral_pubkey=self.ephemeral_pubkey,
+ ephemeral_signature=raw_signature,
+ pfs_hash=pfs
+ )
+
+ # 4) Serialize and send
+ pkt = handshake.serialize()
+ self._send_packet(self.connections[0], pkt, "HANDSHAKE")
+ self.state["handshake_sent"] = True
+ return True
+
+ def enable_auto_responder(self, enable: bool):
+ """
+ Legacy method for enabling/disabling auto responder.
+ For new code, use start_auto_mode() and stop_auto_mode() instead.
+ """
+ self.auto_responder = enable
+ print(f"{YELLOW}[LEGACY]{RESET} Auto responder set to {enable}. Consider using auto_mode instead.")
+
+ # -------------------------------------------------------------------------
+ # Manual Responses
+ # -------------------------------------------------------------------------
+
+ def respond_to_ping(self, index: int, answer: int):
+ """
+ Respond to a ping request with the specified answer (0 = no, 1 = yes).
+ If answer is 1, we accept the connection and use the cipher specified in the request.
+ """
+ if index < 0 or index >= len(self.inbound_messages):
+ print(f"{RED}[ERROR]{RESET} Invalid index {index}.")
+ return False
+ msg = self.inbound_messages[index]
+ if msg["type"] != "PING_REQUEST":
+ print(f"{RED}[ERROR]{RESET} inbound_messages[{index}] is not a PING_REQUEST.")
+ return False
+
+ ping_request = msg["parsed"]
+ version = ping_request.version
+ cipher = ping_request.cipher
+
+ # Force cipher to 0 or 1 (only AES-256-GCM and ChaCha20-Poly1305 are supported)
+ if cipher != 0 and cipher != 1:
+ print(f"{YELLOW}[NOTICE]{RESET} Received PING with unsupported cipher ({cipher}); forcing cipher to 0 in response.")
+ cipher = 0
+
+ # Store the negotiated cipher type if we're accepting
+ if answer == 1:
+ self.cipher_type = cipher
+
+ conn = msg["connection"]
+ # Create ping response
+ ping_response = PingResponse(version, cipher, answer)
+ resp = ping_response.serialize()
+ self._send_packet(conn, resp, "PING_RESPONSE")
+ print(f"{BLUE}[MANUAL]{RESET} Responded to ping with answer={answer}.")
+ return True
+
+ def generate_ecdhe(self, index: int):
+ """
+ Process a handshake message:
+ 1. Verify the ephemeral signature
+ 2. Compute the ECDH shared secret
+ 3. Update PFS history
+ """
+ if index < 0 or index >= len(self.inbound_messages):
+ print(f"{RED}[ERROR]{RESET} Invalid index {index}.")
+ return False
+ msg = self.inbound_messages[index]
+ if msg["type"] != "HANDSHAKE":
+ print(f"{RED}[ERROR]{RESET} inbound_messages[{index}] is not a HANDSHAKE.")
+ return False
+
+ handshake = msg["parsed"]
+
+ # Convert raw signature to DER for verification
+ raw_sig = handshake.ephemeral_signature
+ sig_der = raw_signature_to_der(raw_sig)
+
+ # Verify signature
+ ok = verify_signature(self.peer_identity_pubkey_obj, sig_der, handshake.ephemeral_pubkey)
+ if not ok:
+ print(f"{RED}[ERROR]{RESET} Ephemeral signature invalid.")
+ return False
+ print(f"{GREEN}[OK]{RESET} Ephemeral signature verified.")
+
+ # Check if we have ephemeral keys
+ if not self.ephemeral_privkey:
+ print(f"{YELLOW}[WARN]{RESET} No ephemeral_privkey available, cannot compute shared secret.")
+ return False
+
+ # Compute ECDH shared secret
+ shared = compute_ecdh_shared_key(self.ephemeral_privkey, handshake.ephemeral_pubkey)
+ self.shared_secret = shared.hex()
+ print(f"{GREEN}[OK]{RESET} Computed ECDH shared key = {self.shared_secret}")
+
+ # Update PFS history
+ old_session, _ = self.pfs_history.get(self.peer_identity_pubkey_bytes, (-1, ""))
+ new_session = 1 if old_session < 0 else old_session + 1
+ self.pfs_history[self.peer_identity_pubkey_bytes] = (new_session, self.shared_secret)
+ return True
+
+ # -------------------------------------------------------------------------
+ # Utility
+ # -------------------------------------------------------------------------
+
+ def _send_packet(self, conn: transmission.PeerConnection, data: bytes, label: str):
+ bits_count = len(data) * 8
+ print(f"{BLUE}[SEND]{RESET} {label} -> {bits_count} bits: {data.hex()[:60]}{'...' if len(data.hex())>60 else ''}")
+ conn.send(data)
+
+ def show_state(self):
+ print(f"\n{YELLOW}=== Global State ==={RESET}")
+ print(f"Listening Port: {self.local_port}")
+ print(f"Identity PubKey: 512 bits => {self.identity_pubkey.hex()[:16]}...")
+
+ if self.peer_identity_pubkey_bytes:
+ print(f"Peer Identity PubKey: 512 bits => {self.peer_identity_pubkey_bytes.hex()[:16]}...")
+ else:
+ print("Peer Identity PubKey: [None]")
+
+ print("\nEphemeral Keys:")
+ if self.ephemeral_pubkey:
+ print(f" ephemeral_pubkey: 512 bits => {self.ephemeral_pubkey.hex()[:16]}...")
+ else:
+ print(" ephemeral_pubkey: [None]")
+
+ print(f"\nShared Secret: {self.shared_secret if self.shared_secret else '[None]'}")
+
+ if self.hkdf_key:
+ print(f"HKDF Derived Key: {self.hkdf_key} (size: {len(self.hkdf_key)*8} bits)")
+ else:
+ print("HKDF Derived Key: [None]")
+
+ print(f"Negotiated Cipher: {'AES-256-GCM' if self.cipher_type == 0 else 'ChaCha20-Poly1305'} (code: {self.cipher_type})")
+
+ if self.session_nonce:
+ print(f"Session Nonce: {self.session_nonce.hex()} (129 bits)")
+ else:
+ print("Session Nonce: [None]")
+
+ if self.last_iv:
+ print(f"Last IV: {self.last_iv.hex()} (96 bits)")
+ else:
+ print("Last IV: [None]")
+
+ print("\nProtocol Flags:")
+ for k, v in self.state.items():
+ print(f" {k}: {v}")
+
+ print("\nAuto Mode Active:", self.auto_mode.active)
+ print("Auto Mode State:", self.auto_mode.state)
+ print("Legacy Auto Responder:", self.auto_responder)
+
+ print("\nVoice Status:")
+ print(f" Active: {self.voice_session_active}")
+ if self.voice_session_id:
+ print(f" Session ID: {self.voice_session_id:016x}")
+ print(f" Voice Protocol: {'Initialized' if self.voice_protocol else 'Not initialized'}")
+
+ print("\nActive Connections:")
+ for i, c in enumerate(self.connections):
+ print(f" [{i}] Alive={c.alive}")
+
+ print("\nInbound Message Queue:")
+ for i, m in enumerate(self.inbound_messages):
+ print(f" [{i}] type={m['type']} len={len(m['raw'])} bytes => {len(m['raw']) * 8} bits")
+ print()
+
+ def stop(self):
+ """Stop the protocol and clean up resources."""
+ # Stop auto mode first
+ self.auto_mode.stop()
+
+ # Stop server listener
+ self.server_listener.stop()
+
+ # Close all connections
+ for c in self.connections:
+ c.close()
+ self.connections.clear()
+ self.inbound_messages.clear()
+ print(f"{RED}[STOP]{RESET} Protocol stopped.")
+
+ # -------------------------------------------------------------------------
+ # Encrypted Messaging
+ # -------------------------------------------------------------------------
+
+ def send_encrypted_message(self, plaintext: str):
+ """
+ Encrypts and sends a message using the derived HKDF key and negotiated cipher.
+ The message format is:
+ - Header (18 bytes): flag, data_len, retry, connection_status, IV
+ - Payload: variable length encrypted data
+ - Footer: Authentication tag (16 bytes) + optional CRC32 (4 bytes)
+ """
+ if not self.connections:
+ print(f"{RED}[ERROR]{RESET} No active connections.")
+ return False
+ if not self.hkdf_key:
+ print(f"{RED}[ERROR]{RESET} No HKDF key derived. Cannot encrypt message.")
+ return False
+
+ # Get the encryption key
+ key = bytes.fromhex(self.hkdf_key)
+
+ # Convert plaintext to bytes
+ plaintext_bytes = plaintext.encode('utf-8')
+
+ # Generate or increment the IV
+ if self.last_iv is None:
+ # First message, generate random IV
+ iv = generate_iv(initial=True)
+ else:
+ # Subsequent message, increment previous IV
+ iv = generate_iv(initial=False, previous_iv=self.last_iv)
+
+ # Store the new IV
+ self.last_iv = iv
+
+ # Create encrypted message (connection_status 0 = no CRC)
+ encrypted = encrypt_message(
+ plaintext=plaintext_bytes,
+ key=key,
+ flag=0xBEEF, # Default flag
+ retry=0,
+ connection_status=0, # No CRC
+ iv=iv,
+ cipher_type=self.cipher_type
+ )
+
+ # Send the encrypted message
+ self._send_packet(self.connections[0], encrypted, "ENCRYPTED_MESSAGE")
+ print(f"{GREEN}[SEND_ENCRYPTED]{RESET} Encrypted message sent.")
+ return True
+
+ def decrypt_received_message(self, index: int):
+ """
+ Decrypt a received encrypted message using the HKDF key and negotiated cipher.
+ """
+ if index < 0 or index >= len(self.inbound_messages):
+ print(f"{RED}[ERROR]{RESET} Invalid message index.")
+ return None
+
+ msg = self.inbound_messages[index]
+ if msg["type"] != "ENCRYPTED_MESSAGE":
+ print(f"{RED}[ERROR]{RESET} Message at index {index} is not an ENCRYPTED_MESSAGE.")
+ return None
+
+ # Get the encrypted message
+ encrypted = msg["raw"]
+
+ if not self.hkdf_key:
+ print(f"{RED}[ERROR]{RESET} No HKDF key derived. Cannot decrypt message.")
+ return None
+
+ # Get the encryption key
+ key = bytes.fromhex(self.hkdf_key)
+
+ try:
+ # Decrypt the message
+ plaintext = decrypt_message(encrypted, key, self.cipher_type)
+
+ # Convert to string
+ plaintext_str = plaintext.decode('utf-8')
+
+ # Update last IV from the header
+ header = MessageHeader.unpack(encrypted[:18])
+ self.last_iv = header.iv
+
+ print(f"{GREEN}[DECRYPTED]{RESET} Decrypted message: {plaintext_str}")
+ return plaintext_str
+ except Exception as e:
+ print(f"{RED}[ERROR]{RESET} Decryption failed: {e}")
+ return None
+
+ # -------------------------------------------------------------------------
+ # Voice Protocol Methods
+ # -------------------------------------------------------------------------
+
+ def handle_voice_start(self, index: int):
+ """Handle incoming voice call initiation."""
+ if index < 0 or index >= len(self.inbound_messages):
+ return
+
+ msg = self.inbound_messages[index]
+ voice_start = msg["parsed"]
+
+ print(f"{BLUE}[VOICE]{RESET} Incoming voice call (session_id={voice_start.session_id:016x})")
+ print(f" Codec mode: {voice_start.codec_mode}")
+ print(f" FEC type: {voice_start.fec_type}")
+
+ # Auto-accept if in auto mode (or implement your own logic)
+ if self.auto_mode.active:
+ self.accept_voice_call(voice_start.session_id, voice_start.codec_mode, voice_start.fec_type)
+
+ def handle_voice_ack(self, index: int):
+ """Handle voice call acknowledgment."""
+ if index < 0 or index >= len(self.inbound_messages):
+ return
+
+ msg = self.inbound_messages[index]
+ voice_ack = msg["parsed"]
+
+ if voice_ack.status == 1:
+ print(f"{GREEN}[VOICE]{RESET} Voice call accepted (session_id={voice_ack.session_id:016x})")
+ self.voice_session_active = True
+ self.voice_session_id = voice_ack.session_id
+
+ # Initialize voice protocol if not already done
+ if not self.voice_protocol:
+ self.voice_protocol = VoiceProtocol(self)
+ else:
+ print(f"{RED}[VOICE]{RESET} Voice call rejected")
+
+ def handle_voice_end(self, index: int):
+ """Handle voice call termination."""
+ if index < 0 or index >= len(self.inbound_messages):
+ return
+
+ msg = self.inbound_messages[index]
+ voice_end = msg["parsed"]
+
+ print(f"{YELLOW}[VOICE]{RESET} Voice call ended (session_id={voice_end.session_id:016x})")
+
+ if self.voice_session_id == voice_end.session_id:
+ self.voice_session_active = False
+ self.voice_session_id = None
+
+ def handle_voice_sync(self, index: int):
+ """Handle voice synchronization frame."""
+ if index < 0 or index >= len(self.inbound_messages):
+ return
+
+ msg = self.inbound_messages[index]
+ voice_sync = msg["parsed"]
+
+ # Use sync info for timing/jitter buffer management
+ print(f"{BLUE}[VOICE-SYNC]{RESET} seq={voice_sync.sequence}, ts={voice_sync.timestamp}ms")
+
+ def start_voice_call(self, codec_mode: int = 5, fec_type: int = 0):
+ """
+ Initiate a voice call.
+
+ Args:
+ codec_mode: Codec2 mode (default 5 = 1200bps)
+ fec_type: FEC type (0=repetition, 1=convolutional, 2=LDPC)
+ """
+ if not self.connections:
+ print(f"{RED}[ERROR]{RESET} No active connections.")
+ return False
+
+ if not self.state.get("key_exchange_complete"):
+ print(f"{RED}[ERROR]{RESET} Key exchange not complete. Cannot start voice call.")
+ return False
+
+ # Create VOICE_START message
+ voice_start = VoiceStart(
+ version=0,
+ codec_mode=codec_mode,
+ fec_type=fec_type
+ )
+
+ self.voice_session_id = voice_start.session_id
+
+ # Send the message
+ pkt = voice_start.serialize()
+ self._send_packet(self.connections[0], pkt, "VOICE_START")
+
+ print(f"{GREEN}[VOICE]{RESET} Initiating voice call (session_id={self.voice_session_id:016x})")
+ return True
+
+ def accept_voice_call(self, session_id: int, codec_mode: int, fec_type: int):
+ """Accept an incoming voice call."""
+ if not self.connections:
+ return False
+
+ # Send VOICE_ACK
+ voice_ack = VoiceAck(
+ version=0,
+ status=1, # Accept
+ codec_mode=codec_mode,
+ fec_type=fec_type,
+ session_id=session_id
+ )
+
+ pkt = voice_ack.serialize()
+ self._send_packet(self.connections[0], pkt, "VOICE_ACK")
+
+ self.voice_session_active = True
+ self.voice_session_id = session_id
+
+ # Initialize voice protocol
+ if not self.voice_protocol:
+ self.voice_protocol = VoiceProtocol(self)
+
+ return True
+
+ def end_voice_call(self):
+ """End the current voice call."""
+ if not self.voice_session_active or not self.voice_session_id:
+ print(f"{YELLOW}[VOICE]{RESET} No active voice call to end")
+ return False
+
+ if not self.connections:
+ return False
+
+ # Send VOICE_END
+ voice_end = VoiceEnd(self.voice_session_id)
+ pkt = voice_end.serialize()
+ self._send_packet(self.connections[0], pkt, "VOICE_END")
+
+ self.voice_session_active = False
+ self.voice_session_id = None
+
+ print(f"{YELLOW}[VOICE]{RESET} Voice call ended")
+ return True
+
+ def send_voice_audio(self, audio_samples):
+ """
+ Send voice audio samples.
+
+ Args:
+ audio_samples: PCM audio samples (8kHz, 16-bit)
+ """
+ if not self.voice_session_active:
+ print(f"{RED}[ERROR]{RESET} No active voice session")
+ return False
+
+ if not self.voice_protocol:
+ print(f"{RED}[ERROR]{RESET} Voice protocol not initialized")
+ return False
+
+ try:
+ # Process and send audio
+ modulated = self.voice_protocol.process_voice_input(audio_samples)
+ if modulated is not None:
+ # In real implementation, this would go through the audio channel
+ # For now, we could send it as encrypted data
+ print(f"{BLUE}[VOICE-AUDIO]{RESET} Processed {len(modulated)} samples")
+ return True
+ except Exception as e:
+ print(f"{RED}[ERROR]{RESET} Voice audio processing failed: {e}")
+ import traceback
+ traceback.print_exc()
+
+ return False
diff --git a/protocol_prototype/Prototype/Protocol_Alpha_0/transmission.py b/protocol_prototype/Prototype/Protocol_Alpha_0/transmission.py
new file mode 100644
index 0000000..35f3a21
--- /dev/null
+++ b/protocol_prototype/Prototype/Protocol_Alpha_0/transmission.py
@@ -0,0 +1,100 @@
+import socket
+import threading
+from typing import Callable
+
+class PeerConnection:
+ """
+ Represents a live, two-way connection to a peer.
+ We keep a socket open, read data in a background thread,
+ and can send data from the main thread at any time.
+ """
+ def __init__(self, sock: socket.socket, on_data_received: Callable[['PeerConnection', bytes], None]):
+ self.sock = sock
+ self.on_data_received = on_data_received
+ self.alive = True
+
+ self.read_thread = threading.Thread(target=self.read_loop, daemon=True)
+ self.read_thread.start()
+
+ def read_loop(self):
+ while self.alive:
+ try:
+ data = self.sock.recv(4096)
+ if not data:
+ break
+ self.on_data_received(self, data)
+ except OSError:
+ break
+ self.alive = False
+ self.sock.close()
+ print("[PeerConnection] Connection closed.")
+
+ def send(self, data: bytes):
+ if not self.alive:
+ print("[PeerConnection.send] Cannot send, connection not alive.")
+ return
+ try:
+ self.sock.sendall(data)
+ except OSError:
+ print("[PeerConnection.send] Send failed, connection might be closed.")
+ self.alive = False
+
+ def close(self):
+ self.alive = False
+ try:
+ self.sock.shutdown(socket.SHUT_RDWR)
+ except OSError:
+ pass
+ self.sock.close()
+
+
+class ServerListener(threading.Thread):
+ """
+ A thread that listens on a given port. When a new client connects,
+ it creates a PeerConnection for that client.
+ """
+ def __init__(self, host: str, port: int,
+ on_new_connection: Callable[[PeerConnection], None],
+ on_data_received: Callable[[PeerConnection, bytes], None]):
+ super().__init__(daemon=True)
+ self.host = host
+ self.port = port
+ self.on_new_connection = on_new_connection
+ self.on_data_received = on_data_received
+ self.server_socket = None
+ self.stop_event = threading.Event()
+
+ def run(self):
+ self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.server_socket.bind((self.host, self.port))
+ self.server_socket.listen(5)
+ self.server_socket.settimeout(1.0)
+ print(f"[ServerListener] Listening on {self.host}:{self.port}")
+
+ while not self.stop_event.is_set():
+ try:
+ client_sock, addr = self.server_socket.accept()
+ print(f"[ServerListener] Accepted connection from {addr}")
+ conn = PeerConnection(client_sock, self.on_data_received)
+ self.on_new_connection(conn)
+ except socket.timeout:
+ pass
+ except OSError:
+ break
+
+ if self.server_socket:
+ self.server_socket.close()
+
+ def stop(self):
+ self.stop_event.set()
+ if self.server_socket:
+ self.server_socket.close()
+
+
+def connect_to_peer(host: str, port: int,
+ on_data_received: Callable[[PeerConnection, bytes], None]) -> PeerConnection:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.connect((host, port))
+ print(f"[connect_to_peer] Connected to {host}:{port}")
+ conn = PeerConnection(sock, on_data_received)
+ return conn
diff --git a/protocol_prototype/Prototype/Protocol_Alpha_0/voice_codec.py b/protocol_prototype/Prototype/Protocol_Alpha_0/voice_codec.py
new file mode 100644
index 0000000..c8e70be
--- /dev/null
+++ b/protocol_prototype/Prototype/Protocol_Alpha_0/voice_codec.py
@@ -0,0 +1,716 @@
+"""
+Voice codec integration for encrypted voice over GSM.
+Implements Codec2 compression with FSK modulation for transmitting
+encrypted voice data over standard GSM voice channels.
+"""
+
+import array
+import math
+import struct
+from typing import Optional, Tuple, List
+from dataclasses import dataclass
+from enum import IntEnum
+
+try:
+ import numpy as np
+ HAS_NUMPY = True
+except ImportError:
+ HAS_NUMPY = False
+
+# ANSI colors
+RED = "\033[91m"
+GREEN = "\033[92m"
+YELLOW = "\033[93m"
+BLUE = "\033[94m"
+RESET = "\033[0m"
+
+
+class Codec2Mode(IntEnum):
+ """Codec2 bitrate modes."""
+ MODE_3200 = 0 # 3200 bps
+ MODE_2400 = 1 # 2400 bps
+ MODE_1600 = 2 # 1600 bps
+ MODE_1400 = 3 # 1400 bps
+ MODE_1300 = 4 # 1300 bps
+ MODE_1200 = 5 # 1200 bps (recommended for robustness)
+ MODE_700C = 6 # 700 bps
+
+
+@dataclass
+class Codec2Frame:
+ """Represents a single Codec2 compressed voice frame."""
+ mode: Codec2Mode
+ bits: bytes
+ timestamp: float
+ frame_number: int
+
+
+class Codec2Wrapper:
+ """
+ Wrapper for Codec2 voice codec.
+ In production, this would use py_codec2 or ctypes bindings to libcodec2.
+ This is a simulation interface for protocol development.
+ """
+
+ # Frame sizes in bits for each mode
+ FRAME_BITS = {
+ Codec2Mode.MODE_3200: 64,
+ Codec2Mode.MODE_2400: 48,
+ Codec2Mode.MODE_1600: 64,
+ Codec2Mode.MODE_1400: 56,
+ Codec2Mode.MODE_1300: 52,
+ Codec2Mode.MODE_1200: 48,
+ Codec2Mode.MODE_700C: 28
+ }
+
+ # Frame duration in ms
+ FRAME_MS = {
+ Codec2Mode.MODE_3200: 20,
+ Codec2Mode.MODE_2400: 20,
+ Codec2Mode.MODE_1600: 40,
+ Codec2Mode.MODE_1400: 40,
+ Codec2Mode.MODE_1300: 40,
+ Codec2Mode.MODE_1200: 40,
+ Codec2Mode.MODE_700C: 40
+ }
+
+ def __init__(self, mode: Codec2Mode = Codec2Mode.MODE_1200):
+ """
+ Initialize Codec2 wrapper.
+
+ Args:
+ mode: Codec2 bitrate mode (default 1200 bps for robustness)
+ """
+ self.mode = mode
+ self.frame_bits = self.FRAME_BITS[mode]
+ self.frame_bytes = (self.frame_bits + 7) // 8
+ self.frame_ms = self.FRAME_MS[mode]
+ self.frame_samples = int(8000 * self.frame_ms / 1000) # 8kHz sampling
+ self.frame_counter = 0
+
+ print(f"{GREEN}[CODEC2]{RESET} Initialized in mode {mode.name} "
+ f"({self.frame_bits} bits/frame, {self.frame_ms}ms duration)")
+
+ def encode(self, audio_samples) -> Optional[Codec2Frame]:
+ """
+ Encode PCM audio samples to Codec2 frame.
+
+ Args:
+ audio_samples: PCM samples (8kHz, 16-bit signed)
+
+ Returns:
+ Codec2Frame or None if insufficient samples
+ """
+ if len(audio_samples) < self.frame_samples:
+ return None
+
+ # In production: call codec2_encode(state, bits, samples)
+ # Simulation: create pseudo-compressed data
+ compressed = self._simulate_compression(audio_samples[:self.frame_samples])
+
+ frame = Codec2Frame(
+ mode=self.mode,
+ bits=compressed,
+ timestamp=self.frame_counter * self.frame_ms / 1000.0,
+ frame_number=self.frame_counter
+ )
+
+ self.frame_counter += 1
+ return frame
+
+ def decode(self, frame: Codec2Frame):
+ """
+ Decode Codec2 frame to PCM audio samples.
+
+ Args:
+ frame: Codec2 compressed frame
+
+ Returns:
+ PCM samples (8kHz, 16-bit signed)
+ """
+ if frame.mode != self.mode:
+ raise ValueError(f"Frame mode {frame.mode} doesn't match decoder mode {self.mode}")
+
+ # In production: call codec2_decode(state, samples, bits)
+ # Simulation: decompress to audio
+ return self._simulate_decompression(frame.bits)
+
+ def _simulate_compression(self, samples) -> bytes:
+ """Simulate Codec2 compression (for testing)."""
+ # Convert to list if needed
+ if hasattr(samples, 'tolist'):
+ sample_list = samples.tolist()
+ elif hasattr(samples, '__iter__'):
+ sample_list = list(samples)
+ else:
+ sample_list = samples
+
+ # Extract basic features for simulation
+ if HAS_NUMPY and hasattr(samples, '__array__'):
+ # Convert to numpy array if needed
+ np_samples = np.asarray(samples, dtype=np.float32)
+ if len(np_samples) > 0:
+ mean_square = np.mean(np_samples ** 2)
+ energy = np.sqrt(mean_square) if not np.isnan(mean_square) else 0.0
+ zero_crossings = np.sum(np.diff(np.sign(np_samples)) != 0)
+ else:
+ energy = 0.0
+ zero_crossings = 0
+ else:
+ # Manual calculation without numpy
+ if sample_list and len(sample_list) > 0:
+ energy = math.sqrt(sum(s**2 for s in sample_list) / len(sample_list))
+ zero_crossings = sum(1 for i in range(1, len(sample_list))
+ if (sample_list[i-1] >= 0) != (sample_list[i] >= 0))
+ else:
+ energy = 0.0
+ zero_crossings = 0
+
+ # Pack into bytes (simplified)
+ # Ensure values are valid
+ energy_int = max(0, min(65535, int(energy)))
+ zc_int = max(0, min(65535, int(zero_crossings)))
+ data = struct.pack('= 4:
+ energy, zero_crossings = struct.unpack('> 6) & 0x03,
+ (byte >> 4) & 0x03,
+ (byte >> 2) & 0x03,
+ byte & 0x03
+ ])
+
+ # Generate audio signal
+ signal = []
+
+ # Add preamble
+ if add_preamble:
+ preamble_samples = int(self.preamble_duration * self.sample_rate)
+ if HAS_NUMPY:
+ t = np.arange(preamble_samples) / self.sample_rate
+ preamble = np.sin(2 * np.pi * self.preamble_freq * t)
+ signal.extend(preamble)
+ else:
+ for i in range(preamble_samples):
+ t = i / self.sample_rate
+ value = math.sin(2 * math.pi * self.preamble_freq * t)
+ signal.append(value)
+
+ # Modulate symbols
+ for symbol in symbols:
+ freq = self.frequencies[symbol]
+ if HAS_NUMPY:
+ t = np.arange(self.samples_per_symbol) / self.sample_rate
+ tone = np.sin(2 * np.pi * freq * t)
+ signal.extend(tone)
+ else:
+ for i in range(self.samples_per_symbol):
+ t = i / self.sample_rate
+ value = math.sin(2 * math.pi * freq * t)
+ signal.append(value)
+
+ # Apply smoothing to reduce clicks
+ if HAS_NUMPY:
+ audio = np.array(signal, dtype=np.float32)
+ else:
+ audio = array.array('f', signal)
+ audio = self._apply_envelope(audio)
+
+ return audio
+
+ def demodulate(self, audio) -> Tuple[bytes, float]:
+ """
+ Demodulate FSK audio signal to binary data.
+
+ Args:
+ audio: Audio signal
+
+ Returns:
+ Tuple of (demodulated data, confidence score)
+ """
+ # Find preamble
+ preamble_start = self._find_preamble(audio)
+ if preamble_start < 0:
+ return b'', 0.0
+
+ # Skip preamble
+ data_start = preamble_start + int(self.preamble_duration * self.sample_rate)
+
+ # Demodulate symbols
+ symbols = []
+ confidence_scores = []
+
+ pos = data_start
+ while pos + self.samples_per_symbol <= len(audio):
+ symbol_audio = audio[pos:pos + self.samples_per_symbol]
+ symbol, confidence = self._demodulate_symbol(symbol_audio)
+ symbols.append(symbol)
+ confidence_scores.append(confidence)
+ pos += self.samples_per_symbol
+
+ # Convert symbols to bytes
+ data = bytearray()
+ for i in range(0, len(symbols), 4):
+ if i + 3 < len(symbols):
+ byte = (symbols[i] << 6) | (symbols[i+1] << 4) | (symbols[i+2] << 2) | symbols[i+3]
+ data.append(byte)
+
+ if HAS_NUMPY and confidence_scores:
+ avg_confidence = np.mean(confidence_scores)
+ else:
+ avg_confidence = sum(confidence_scores) / len(confidence_scores) if confidence_scores else 0.0
+ return bytes(data), avg_confidence
+
+ def _find_preamble(self, audio) -> int:
+ """Find preamble in audio signal."""
+ # Simple energy-based detection
+ window_size = int(0.01 * self.sample_rate) # 10ms window
+
+ if HAS_NUMPY:
+ for i in range(0, len(audio) - window_size, window_size // 2):
+ window = audio[i:i + window_size]
+
+ # Check for preamble frequency
+ fft = np.fft.fft(window)
+ freqs = np.fft.fftfreq(len(window), 1/self.sample_rate)
+
+ # Find peak near preamble frequency
+ idx = np.argmax(np.abs(fft[:len(fft)//2]))
+ peak_freq = abs(freqs[idx])
+
+ if abs(peak_freq - self.preamble_freq) < 50: # 50 Hz tolerance
+ return i
+ else:
+ # Simple zero-crossing based detection without FFT
+ for i in range(0, len(audio) - window_size, window_size // 2):
+ window = list(audio[i:i + window_size])
+
+ # Count zero crossings
+ zero_crossings = 0
+ for j in range(1, len(window)):
+ if (window[j-1] >= 0) != (window[j] >= 0):
+ zero_crossings += 1
+
+ # Estimate frequency from zero crossings
+ estimated_freq = (zero_crossings * self.sample_rate) / (2 * len(window))
+
+ if abs(estimated_freq - self.preamble_freq) < 100: # 100 Hz tolerance
+ return i
+
+ return -1
+
+ def _demodulate_symbol(self, audio) -> Tuple[int, float]:
+ """Demodulate a single FSK symbol."""
+ if HAS_NUMPY:
+ # FFT-based demodulation
+ fft = np.fft.fft(audio)
+ freqs = np.fft.fftfreq(len(audio), 1/self.sample_rate)
+ magnitude = np.abs(fft[:len(fft)//2])
+
+ # Find energy at each FSK frequency
+ energies = []
+ for freq in self.frequencies:
+ idx = np.argmin(np.abs(freqs[:len(freqs)//2] - freq))
+ energy = magnitude[idx]
+ energies.append(energy)
+
+ # Select symbol with highest energy
+ symbol = np.argmax(energies)
+ else:
+ # Goertzel algorithm for specific frequency detection
+ audio_list = list(audio) if hasattr(audio, '__iter__') else audio
+ energies = []
+
+ for freq in self.frequencies:
+ # Goertzel algorithm
+ omega = 2 * math.pi * freq / self.sample_rate
+ coeff = 2 * math.cos(omega)
+
+ s_prev = 0
+ s_prev2 = 0
+
+ for sample in audio_list:
+ s = sample + coeff * s_prev - s_prev2
+ s_prev2 = s_prev
+ s_prev = s
+
+ # Calculate magnitude
+ power = s_prev2 * s_prev2 + s_prev * s_prev - coeff * s_prev * s_prev2
+ energies.append(math.sqrt(abs(power)))
+
+ # Select symbol with highest energy
+ symbol = energies.index(max(energies))
+
+ # Confidence is ratio of strongest to second strongest
+ sorted_energies = sorted(energies, reverse=True)
+ confidence = sorted_energies[0] / (sorted_energies[1] + 1e-6)
+
+ return symbol, min(confidence, 10.0) / 10.0
+
+ def _apply_envelope(self, audio):
+ """Apply smoothing envelope to reduce clicks."""
+ # Simple raised cosine envelope
+ ramp_samples = int(0.002 * self.sample_rate) # 2ms ramps
+
+ if len(audio) > 2 * ramp_samples:
+ if HAS_NUMPY:
+ # Fade in
+ t = np.linspace(0, np.pi/2, ramp_samples)
+ audio[:ramp_samples] *= np.sin(t) ** 2
+
+ # Fade out
+ audio[-ramp_samples:] *= np.sin(t[::-1]) ** 2
+ else:
+ # Manual fade in
+ for i in range(ramp_samples):
+ t = (i / ramp_samples) * (math.pi / 2)
+ factor = math.sin(t) ** 2
+ audio[i] *= factor
+
+ # Manual fade out
+ for i in range(ramp_samples):
+ t = ((ramp_samples - 1 - i) / ramp_samples) * (math.pi / 2)
+ factor = math.sin(t) ** 2
+ audio[-(i+1)] *= factor
+
+ return audio
+
+
+class VoiceProtocol:
+ """
+ Integrates voice codec and modem with the Icing protocol
+ for encrypted voice transmission over GSM.
+ """
+
+ def __init__(self, protocol_instance):
+ """
+ Initialize voice protocol handler.
+
+ Args:
+ protocol_instance: IcingProtocol instance
+ """
+ self.protocol = protocol_instance
+ self.codec = Codec2Wrapper(Codec2Mode.MODE_1200)
+ self.modem = FSKModem(sample_rate=8000, baud_rate=600)
+
+ # Voice crypto state
+ self.voice_iv_counter = 0
+ self.voice_sequence = 0
+
+ # Buffers
+ if HAS_NUMPY:
+ self.audio_buffer = np.array([], dtype=np.int16)
+ else:
+ self.audio_buffer = array.array('h') # 16-bit signed integers
+ self.frame_buffer = []
+
+ print(f"{GREEN}[VOICE]{RESET} Voice protocol initialized")
+
+ def process_voice_input(self, audio_samples):
+ """
+ Process voice input: compress, encrypt, and modulate.
+
+ Args:
+ audio_samples: PCM audio samples (8kHz, 16-bit)
+
+ Returns:
+ Modulated audio signal ready for transmission (numpy array or array.array)
+ """
+ # Add to buffer
+ if HAS_NUMPY:
+ self.audio_buffer = np.concatenate([self.audio_buffer, audio_samples])
+ else:
+ self.audio_buffer.extend(audio_samples)
+
+ # Process complete frames
+ modulated_audio = []
+
+ while len(self.audio_buffer) >= self.codec.frame_samples:
+ # Extract frame
+ if HAS_NUMPY:
+ frame_audio = self.audio_buffer[:self.codec.frame_samples]
+ self.audio_buffer = self.audio_buffer[self.codec.frame_samples:]
+ else:
+ frame_audio = array.array('h', self.audio_buffer[:self.codec.frame_samples])
+ del self.audio_buffer[:self.codec.frame_samples]
+
+ # Compress with Codec2
+ compressed_frame = self.codec.encode(frame_audio)
+ if not compressed_frame:
+ continue
+
+ # Encrypt frame
+ encrypted = self._encrypt_voice_frame(compressed_frame)
+
+ # Add FEC
+ protected = self._add_fec(encrypted)
+
+ # Modulate to audio
+ audio_signal = self.modem.modulate(protected, add_preamble=True)
+ modulated_audio.append(audio_signal)
+
+ if modulated_audio:
+ if HAS_NUMPY:
+ return np.concatenate(modulated_audio)
+ else:
+ # Concatenate array.array objects
+ result = array.array('f')
+ for audio in modulated_audio:
+ result.extend(audio)
+ return result
+ return None
+
+ def process_voice_output(self, modulated_audio):
+ """
+ Process received audio: demodulate, decrypt, and decompress.
+
+ Args:
+ modulated_audio: Received FSK-modulated audio
+
+ Returns:
+ Decoded PCM audio samples (numpy array or array.array)
+ """
+ # Demodulate
+ data, confidence = self.modem.demodulate(modulated_audio)
+
+ if confidence < 0.5:
+ print(f"{YELLOW}[VOICE]{RESET} Low demodulation confidence: {confidence:.2f}")
+ return None
+
+ # Remove FEC
+ frame_data = self._remove_fec(data)
+ if not frame_data:
+ return None
+
+ # Decrypt
+ compressed_frame = self._decrypt_voice_frame(frame_data)
+ if not compressed_frame:
+ return None
+
+ # Decompress
+ audio_samples = self.codec.decode(compressed_frame)
+
+ return audio_samples
+
+ def _encrypt_voice_frame(self, frame: Codec2Frame) -> bytes:
+ """Encrypt a voice frame using ChaCha20-CTR."""
+ if not self.protocol.hkdf_key:
+ raise ValueError("No encryption key available")
+
+ # Prepare frame data
+ frame_data = struct.pack(' Optional[Codec2Frame]:
+ """Decrypt a voice frame."""
+ if len(data) < 10:
+ return None
+
+ # Extract sequence and IV hint
+ sequence, iv_hint = struct.unpack(' bytes:
+ """Add forward error correction."""
+ # Simple repetition code (3x) for testing
+ # In production: use convolutional code or LDPC
+ fec_data = bytearray()
+
+ for byte in data:
+ # Repeat each byte 3 times
+ fec_data.extend([byte, byte, byte])
+
+ return bytes(fec_data)
+
+ def _remove_fec(self, data: bytes) -> Optional[bytes]:
+ """Remove FEC and correct errors."""
+ if len(data) % 3 != 0:
+ return None
+
+ corrected = bytearray()
+
+ for i in range(0, len(data), 3):
+ # Majority voting
+ votes = [data[i], data[i+1], data[i+2]]
+ byte_value = max(set(votes), key=votes.count)
+ corrected.append(byte_value)
+
+ return bytes(corrected)
+
+
+# Example usage
+if __name__ == "__main__":
+ # Test Codec2 wrapper
+ print(f"\n{BLUE}=== Testing Codec2 Wrapper ==={RESET}")
+ codec = Codec2Wrapper(Codec2Mode.MODE_1200)
+
+ # Generate test audio
+ if HAS_NUMPY:
+ t = np.linspace(0, 0.04, 320) # 40ms at 8kHz
+ test_audio = (np.sin(2 * np.pi * 440 * t) * 16384).astype(np.int16)
+ else:
+ test_audio = array.array('h')
+ for i in range(320):
+ t = i * 0.04 / 320
+ value = int(math.sin(2 * math.pi * 440 * t) * 16384)
+ test_audio.append(value)
+
+ # Encode
+ frame = codec.encode(test_audio)
+ print(f"Encoded frame: {len(frame.bits)} bytes")
+
+ # Decode
+ decoded = codec.decode(frame)
+ print(f"Decoded audio: {len(decoded)} samples")
+
+ # Test FSK modem
+ print(f"\n{BLUE}=== Testing FSK Modem ==={RESET}")
+ modem = FSKModem()
+
+ # Test data
+ test_data = b"Hello, secure voice!"
+
+ # Modulate
+ modulated = modem.modulate(test_data)
+ print(f"Modulated: {len(modulated)} samples ({len(modulated)/8000:.2f}s)")
+
+ # Demodulate
+ demodulated, confidence = modem.demodulate(modulated)
+ print(f"Demodulated: {demodulated}")
+ print(f"Confidence: {confidence:.2%}")
+ print(f"Match: {demodulated == test_data}")
\ No newline at end of file