Compare commits
18 Commits
22b65fd9fe
...
34d5990a3b
Author | SHA1 | Date | |
---|---|---|---|
|
34d5990a3b | ||
355e040322 | |||
|
e9e50dd4a2 | ||
|
95f747280a | ||
|
10728ad6e0 | ||
f440767b38 | |||
68761b6e44 | |||
71485a4346 | |||
c8ea9204ff | |||
cdd3a470c0 | |||
29dfc0a2ac | |||
31ef173949 | |||
35558c6a43 | |||
ee27a82c0a | |||
4ce487c1c9 | |||
c793793c8e | |||
ed07022916 | |||
675b1c0c7b |
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,6 +9,7 @@
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
protocol_prototype/venv
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
|
77
README.md
77
README.md
@ -1,21 +1,72 @@
|
||||
# Icing
|
||||
---
|
||||
gitea: none
|
||||
include_toc: true
|
||||
---
|
||||
|
||||
## Encrypting phone calls on an analog audio level
|
||||
# Icing – end-to-end-encrypted phone calls without data
|
||||
|
||||
|
||||
Experimental α-stage • Apache-2.0 • Built at Epitech
|
||||
|
||||
 
|
||||
|
||||
> **Icing** runs a Noise-XK handshake, Codec2 and 4-FSK modulation over the plain voice channel, so any GSM/VoLTE call can be upgraded to private, authenticated audio - **no servers and no IP stack required.**
|
||||
|
||||
An Epitech Innovation Project
|
||||
|
||||
*By*
|
||||
**Bartosz Michalak - Alexis Danlos - Florian Griffon - Ange Duhayon - Stéphane Corbière**
|
||||
|
||||
---
|
||||
|
||||
The **docs** folder contains documentation about:
|
||||
|
||||
#### Epitech
|
||||
- The Beta Test Plan
|
||||
- The Delivrables
|
||||
|
||||
#### Icing
|
||||
- The project
|
||||
- A user manual
|
||||
- Our automations
|
||||
## 📖 Detailed design
|
||||
|
||||
See [`docs/Icing.md`](docs/Icing.md) for protocol goals, threat model, and technical architecture.
|
||||
|
||||
## 🔨 Quick start (developer preview, un-protocoled dialer)
|
||||
|
||||
```bash
|
||||
# on an Android phone (Android 12+)
|
||||
git clone https://git.gmoker.com/icing/monorepo
|
||||
cd dialer
|
||||
# Requires Flutter and ADB or a virtual device
|
||||
flutter run
|
||||
```
|
||||
|
||||
> ⚠️ This is an **alpha prototype**: expect crashes, missing UX, and incomplete FEC.
|
||||
|
||||
You can join us in Telegram or Reddit !
|
||||
https://t.me/icingdialer
|
||||
https://www.reddit.com/r/IcingDialer/
|
||||
|
||||
## ✨ Features (α1 snapshot)
|
||||
|
||||
- [DryBox Only] Noise *XK* handshake (X25519, AES-GCM, SHA-256)
|
||||
- Static keys = Ed25519 (QR share)
|
||||
- [DryBox Only] Voice path: Codec2 → encrypted bit-stream → 4-FSK → analog channel
|
||||
- GSM simulation in DryBox for off-device testing
|
||||
|
||||
## 🗺️ Project status
|
||||
|
||||
| Stage (roadmap) | Dialer app | Protocol | DryBox | Docs |
|
||||
| --------------------- | -------------------------- | ------------------ | ----------------- | ----------------- |
|
||||
| **Alpha 1 (Q3-2025)** | 🚧 UI stub, call hook | Key gestion | ⚠️ Qt demo, Alpha 1 Working | 📝 Draft complete |
|
||||
| Alpha 2 (Q4-2025) | 🛠️ Magisk flow | 🔄 Adaptive FEC | 🔄 Stress tests | 🔄 Expanded |
|
||||
| **Beta 1 (Feb 2026)** | 🎉 Public release | 🔐 Audit pass | ✅ CI | ✅ |
|
||||
|
||||
## 🤝 How to help
|
||||
|
||||
|
||||
- **Crypto researchers** – Poke holes in the protocol draft.
|
||||
- **Android security hackers** - Review our Kotlin integrations.
|
||||
- **ROM maintainers** - Let's talk about an integration !
|
||||
|
||||
Open an issue or report here [](https://cryptpad.fr/form/#/2/form/view/Tgm5vQ7aRgR6TKxy8LMJcJ-nu9CVC32IbqYOyOG4iUs/)
|
||||
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 - see [`LICENSE`](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
Made with ☕ by four Epitech students.
|
||||
|
@ -1,28 +0,0 @@
|
||||
package com.icing.dialer
|
||||
|
||||
import java.security.KeyStore
|
||||
|
||||
object KeyDeleterHelper {
|
||||
|
||||
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
|
||||
/**
|
||||
* Deletes the key pair associated with the given alias from the Android Keystore.
|
||||
*
|
||||
* @param alias The alias of the key pair to delete.
|
||||
* @throws Exception if deletion fails.
|
||||
*/
|
||||
fun deleteKeyPair(alias: String) {
|
||||
try {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
|
||||
if (!keyStore.containsAlias(alias)) {
|
||||
throw Exception("No key found with alias \"$alias\" to delete.")
|
||||
}
|
||||
|
||||
keyStore.deleteEntry(alias)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to delete key pair: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
package com.icing.dialer
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
|
||||
object KeyGeneratorHelper {
|
||||
|
||||
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
|
||||
/**
|
||||
* Generates an ECDSA P-256 key pair and stores it in the Android Keystore.
|
||||
*
|
||||
* @param alias Unique identifier for the key pair.
|
||||
* @throws Exception if key generation fails.
|
||||
*/
|
||||
fun generateECKeyPair(alias: String) {
|
||||
try {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
|
||||
// Check if the key already exists
|
||||
if (keyStore.containsAlias(alias)) {
|
||||
throw Exception("Key with alias \"$alias\" already exists.")
|
||||
}
|
||||
|
||||
val keyPairGenerator = KeyPairGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_EC,
|
||||
ANDROID_KEYSTORE
|
||||
)
|
||||
|
||||
val parameterSpec = KeyGenParameterSpec.Builder(
|
||||
alias,
|
||||
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
|
||||
)
|
||||
.setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
|
||||
.setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA384, KeyProperties.DIGEST_SHA512)
|
||||
.setUserAuthenticationRequired(false) // Set to true if you require user authentication
|
||||
.build()
|
||||
|
||||
keyPairGenerator.initialize(parameterSpec)
|
||||
keyPairGenerator.generateKeyPair()
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to generate EC key pair: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
package com.icing.dialer
|
||||
|
||||
import java.security.PrivateKey
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
@ -8,15 +8,21 @@ import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import java.security.Signature
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
|
||||
class KeystoreHelper(private val call: MethodCall, private val result: MethodChannel.Result) {
|
||||
|
||||
private val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
|
||||
fun handleMethodCall() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
result.error("UNSUPPORTED_API", "ED25519 requires Android 11 (API 30) or higher", null)
|
||||
return
|
||||
}
|
||||
when (call.method) {
|
||||
"generateKeyPair" -> generateECKeyPair()
|
||||
"generateKeyPair" -> generateEDKeyPair()
|
||||
"signData" -> signData()
|
||||
"getPublicKey" -> getPublicKey()
|
||||
"deleteKeyPair" -> deleteKeyPair()
|
||||
@ -25,7 +31,7 @@ class KeystoreHelper(private val call: MethodCall, private val result: MethodCha
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateECKeyPair() {
|
||||
private fun generateEDKeyPair() {
|
||||
val alias = call.argument<String>("alias")
|
||||
if (alias == null) {
|
||||
result.error("INVALID_ARGUMENT", "Alias is required", null)
|
||||
@ -44,16 +50,14 @@ class KeystoreHelper(private val call: MethodCall, private val result: MethodCha
|
||||
KeyProperties.KEY_ALGORITHM_EC,
|
||||
ANDROID_KEYSTORE
|
||||
)
|
||||
|
||||
val parameterSpec = KeyGenParameterSpec.Builder(
|
||||
alias,
|
||||
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
|
||||
)
|
||||
.setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
|
||||
.setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA384, KeyProperties.DIGEST_SHA512)
|
||||
.setAlgorithmParameterSpec(ECGenParameterSpec("ed25519"))
|
||||
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||
.setUserAuthenticationRequired(false)
|
||||
.build()
|
||||
|
||||
keyPairGenerator.initialize(parameterSpec)
|
||||
keyPairGenerator.generateKeyPair()
|
||||
|
||||
@ -73,17 +77,14 @@ class KeystoreHelper(private val call: MethodCall, private val result: MethodCha
|
||||
|
||||
try {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
|
||||
val privateKey = keyStore.getKey(alias, null) as? PrivateKey ?: run {
|
||||
result.error("KEY_NOT_FOUND", "Private key not found for alias \"$alias\".", null)
|
||||
return
|
||||
}
|
||||
|
||||
val signature = Signature.getInstance("SHA256withECDSA")
|
||||
val signature = Signature.getInstance("Ed25519")
|
||||
signature.initSign(privateKey)
|
||||
signature.update(data.toByteArray())
|
||||
val signedBytes = signature.sign()
|
||||
|
||||
val signatureBase64 = Base64.encodeToString(signedBytes, Base64.DEFAULT)
|
||||
result.success(signatureBase64)
|
||||
} catch (e: Exception) {
|
||||
|
@ -1,30 +0,0 @@
|
||||
package com.icing.dialer
|
||||
|
||||
import java.security.KeyStore
|
||||
import java.security.PublicKey
|
||||
import android.util.Base64
|
||||
|
||||
object PublicKeyHelper {
|
||||
|
||||
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
|
||||
/**
|
||||
* Retrieves the public key associated with the given alias.
|
||||
*
|
||||
* @param alias The alias of the key pair.
|
||||
* @return The public key as a Base64-encoded string.
|
||||
* @throws Exception if retrieval fails.
|
||||
*/
|
||||
fun getPublicKey(alias: String): String {
|
||||
try {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
|
||||
val certificate = keyStore.getCertificate(alias) ?: throw Exception("Certificate not found for alias \"$alias\".")
|
||||
val publicKey: PublicKey = certificate.publicKey
|
||||
|
||||
return Base64.encodeToString(publicKey.encoded, Base64.DEFAULT)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to retrieve public key: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
package com.icing.dialer
|
||||
|
||||
import android.security.keystore.KeyProperties
|
||||
import java.security.KeyStore
|
||||
import java.security.Signature
|
||||
import android.util.Base64
|
||||
import java.security.PrivateKey
|
||||
|
||||
object SignerHelper {
|
||||
|
||||
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
|
||||
/**
|
||||
* Signs the provided data using the private key associated with the given alias.
|
||||
*
|
||||
* @param alias The alias of the key pair.
|
||||
* @param data The data to sign.
|
||||
* @return The signature as a Base64-encoded string.
|
||||
* @throws Exception if signing fails.
|
||||
*/
|
||||
fun signData(alias: String, data: ByteArray): String {
|
||||
try {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
|
||||
val privateKey = keyStore.getKey(alias, null) as? PrivateKey?: throw Exception("Private key not found for alias \"$alias\".")
|
||||
|
||||
val signature = Signature.getInstance("SHA256withECDSA")
|
||||
signature.initSign(privateKey)
|
||||
signature.update(data)
|
||||
val signedBytes = signature.sign()
|
||||
|
||||
return Base64.encodeToString(signedBytes, Base64.DEFAULT)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to sign data: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
@ -10,7 +10,7 @@ class AsymmetricCryptoService {
|
||||
final String _aliasPrefix = 'icing_';
|
||||
final Uuid _uuid = Uuid();
|
||||
|
||||
/// Generates an ECDSA P-256 key pair with a unique alias and stores its metadata.
|
||||
/// Generates an ED25519 key pair with a unique alias and stores its metadata.
|
||||
Future<String> generateKeyPair({String? label}) async {
|
||||
try {
|
||||
// Generate a unique identifier for the key
|
||||
|
@ -3,7 +3,7 @@
|
||||
An Epitech Innovation Project
|
||||
|
||||
*By*
|
||||
**Bartosz Michalak - Alexis Danlos - Florian Griffon - Ange Duhayon - Stéphane Corbière**
|
||||
**Bartosz Michalak - Alexis Danlos - Florian Griffon - Stéphane Corbière**
|
||||
|
||||
---
|
||||
|
||||
@ -17,13 +17,25 @@ An Epitech Innovation Project
|
||||
|
||||
## Introduction to Icing
|
||||
|
||||
Icing is the name of our project, which is divided in **two interconnected goals**:
|
||||
1. Provide an end-to-end (E2E) encryption **code library**, based on Eliptic Curve Cryptography (ECC), to encrypt phone-calls on an **analog audio** level.
|
||||
2. Provide a reference implementation in the form of a totally seamless Android **smartphone dialer** application, that anybody could use without being aware of its encryption feature.
|
||||
Icing is the name of our project, which is divided in **three interconnected goals**:
|
||||
1. Build a mutual-authentication and end-to-end encryption protocol, NAAP, for half and full-duplex audio communication, network agnostic. Network Agnostic Authentication Protocol.
|
||||
2. Provide a reference implementation in the form of an **Android Package**, that anybody can use to implement the protocol into their application.
|
||||
3. Provide a reference implementation in the form of an **Android Dialer**, that uses the android package, and that could seamlessly replace any Android user's default dialer.
|
||||
|
||||
This idea came naturally to our minds, when we remarked the lack of such tool.
|
||||
|
||||
Where "private messaging" and other "encrypted communication" apps flourish, nowadays, they **all** require an internet access to work.
|
||||
### Setting a new security standard
|
||||
|
||||
#### ***"There is no way to create a backdoor that only the good guys can walk through"***
|
||||
> (*Meredith Whittaker - President of Signal Fundation - July 2023, Channel 4*)
|
||||
|
||||
Enabling strong authentication on the phone network, either cellular or cable, would change the way we use our phone.
|
||||
|
||||
Reduced phone-related scams, simplified and safer banking or government services interactions, reduced dependency to the internet, and more, are the benefits both consumers and organizations would enjoy.
|
||||
|
||||
Encrypting the data end-to-end increases security globally by a considerable factor, particularly in low-bandwidth / no internet areas, and the added privacy would benefit everyone.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Privacy and security in telecoms should not depend on internet availability.
|
||||
|
||||
@ -35,21 +47,6 @@ So in a real-world, stressful and harsh condition, affording privacy or security
|
||||
Our solution is for the every-man that is not even aware of its smart phone weakness, as well as for the activists or journalists surviving in hostile environment around the globe.
|
||||
|
||||
|
||||
### Setting a new security standard
|
||||
|
||||
#### ***"There is no way to create a backdoor that only the good guys can walk through"***
|
||||
> (*Meredith Whittaker - President of Signal Fundation - July 2023, Channel 4*)
|
||||
|
||||
If the police can listen to your calls with a mandate, hackers can, without mandate.
|
||||
|
||||
Many online platforms, such as online bank accounts, uses phone calls, or voicemails to drop security codes needed for authentication. The idea is to bring extra security, by requiring a second factor to authenticate the user, but most voicemails security features have been obsolete for a long time now.
|
||||
|
||||
**But this could change with globalized end-to-end encryption.**
|
||||
|
||||
This not only enables obfuscation of the transmitted audio data, but also hard peer authentication.
|
||||
This means that if you are in an important call, where you could communicate sensitive information such as passwords, or financial orders, using Icing protocol you and your peer would know that there is no man in the middle, listening and stealing information, and that your correspondent really is who it says.
|
||||
|
||||
---
|
||||
|
||||
### Icing's strategy
|
||||
|
||||
|
@ -1,71 +1,182 @@
|
||||
# User Manual for Icing Dialer
|
||||
|
||||
# User Manual
|
||||
## Introduction
|
||||
|
||||
The Icing Dialer is an open-source mobile application designed to enable end-to-end encrypted voice calls over GSM/LTE networks, ensuring privacy without reliance on the internet, servers, or third-party infrastructure. This manual provides comprehensive guidance for three audiences: average users, security experts, and developers. A final section outlines our manual testing policy to ensure the application's reliability and security.
|
||||
|
||||
**Utilization documentation.**
|
||||
|
||||
Written with chapters for the average Joe user, security experts, and developers.
|
||||
|
||||
The average-user section is only about what the average-user will know from Icing: its dialer reference implementation.
|
||||
|
||||
The security expert section will cover all the theory behind our reference implementation, and the Icing protocol. This section can serve as an introduction / transition for the next section:
|
||||
|
||||
The developer section will explain our code architecture and concepts, going in-depth inside the reference implementation and the Icing protocol library.
|
||||
This library will have dedicated documentation in this section, so any developer can implement it in any desired way.
|
||||
|
||||
Lastly, as a continuation of the developer section, the Manual Test section will cover our manual testing policy.
|
||||
- **Average User**: Instructions for using the Icing Dialer as a transparent replacement for the default phone dialer.
|
||||
- **Security Expert**: Technical details of the Icing protocol, including cryptographic mechanisms and implementation.
|
||||
- **Developer**: In-depth explanation of the code architecture, Icing protocol library, and integration guidelines.
|
||||
- **Manual Tests**: Overview of the manual testing policy for validating the application and protocol.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
|
||||
- [Average User](#averageuser)
|
||||
|
||||
- [Security Expert](#icingsstrategy)
|
||||
|
||||
- [Average User](#average-user)
|
||||
- [Security Expert](#security-expert)
|
||||
- [Developer](#developer)
|
||||
|
||||
- [Manual Tests](#manualtests)
|
||||
|
||||
|
||||
- [Manual Tests](#manual-tests)
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Average User
|
||||
|
||||
|
||||
Use the Icing dialer like your normal dialer, if you can't do that we can't help, you dumb retard lmfao.
|
||||
The Icing Dialer is a privacy-focused mobile application that replaces your default phone dialer, providing secure, end-to-end encrypted voice calls over GSM/LTE networks. It is designed to be intuitive and indistinguishable from a standard dialer, ensuring seamless use for all users.
|
||||
|
||||
### Key Features
|
||||
- **Seamless Dialer Replacement**: Functions as a full replacement for your phone’s default dialer, supporting standard call features and encrypted calls.
|
||||
- **Cryptographic Key Pair Generation**: Automatically generates an ED25519 key pair during setup for secure communications, stored securely using the Android Keystore.
|
||||
- **Secure Contact Sharing**: Adds and shares contacts via QR codes or VCF files, ensuring privacy.
|
||||
- **Automatic Call Encryption**: Encrypts calls with compatible Icing Dialer users using the Noise protocol, encoded into the analog audio signal via Codec2 and 4FSK modulation.
|
||||
- **On-the-Fly Pairing**: Detects other Icing Dialer users and offers encrypted pairing during calls (optional, under development).
|
||||
- **Call Management**: Includes call history, contact management, visual voicemail, and features like mute, speaker, and SIM selection.
|
||||
- **Privacy Protection**: Safeguards sensitive communications with secure voice authentication and encrypted voicemail.
|
||||
|
||||
### Getting Started
|
||||
1. **Installation**: Install the Icing Dialer from a trusted source (e.g., a partnered AOSP fork or Magisk module for rooted Android devices).
|
||||
2. **Setup**: Upon first launch, the app generates an ED25519 key pair using the Android Keystore. Follow prompts to complete setup.
|
||||
3. **Adding Contacts**: Use the QR code or VCF import feature to securely add contacts. Scan a contact’s QR code or import a VCF file to establish a secure connection.
|
||||
4. **Making Calls**: Dial numbers using the full dialer interface (numbers, *, #). The app uses the Android Telephony API to detect compatible users and automatically encrypts calls when possible.
|
||||
5. **Encrypted Calls**: Calls to known contacts with public keys are automatically encrypted. A data rate and error indicator provide real-time feedback. Use the disable encryption button if needed.
|
||||
6. **Call History and Contacts**: Access call history with filters for missed, incoming, and outgoing calls. Tap a call to view details or open a contact modal. Manage contacts with search, favorites, and blocklist features.
|
||||
7. **Visual Voicemail**: Play, pause, or manage voicemails with quick links to call, text, block, or share numbers.
|
||||
8. **Settings**: Configure default SIM, manage public keys, and access the blocklist via the settings menu.
|
||||
|
||||
### Troubleshooting
|
||||
- **FAQs**: Available on our Reddit and Telegram channels for common issues and setup guidance.
|
||||
- **Feedback**: Submit feedback via our anonymous CryptPad form for prompt issue resolution.
|
||||
|
||||
### Example Scenarios
|
||||
- **Secure Voicemail Access**: Mathilda, 34, uses Icing to securely retrieve a PayPal authentication code from her voicemail, protected by her registered Icing public key.
|
||||
- **Authenticated Calls**: Jeff, 70, authenticates with his bank using encrypted DTMF transmission, ensuring secure and verified communication.
|
||||
- **Private Communication**: Elise, 42, a journalist, uses Icing to make discreet, encrypted calls despite unreliable or monitored networks.
|
||||
- **Emergency Calls Abroad**: Paul, 22, a developer, uses Icing to securely assist colleagues with a critical issue while abroad, relying only on voice calls.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Security Expert
|
||||
|
||||
SecUriTy eXpeRt
|
||||
The Icing Dialer is the reference implementation of the Icing protocol, an open, decentralized encryption protocol for telephony. This section details the cryptographic and technical foundations, focusing on security principles.
|
||||
|
||||
### Icing Protocol Overview
|
||||
The Icing protocol enables end-to-end encrypted voice calls over GSM/LTE networks by encoding cryptographic data into the analog audio signal. Key components include:
|
||||
- **End-to-End Encryption**: Utilizes the Noise protocol with XX (mutual authentication) and XK (known-key) handshake patterns for secure session establishment, using ED25519 key pairs.
|
||||
- **Perfect Forward Secrecy**: Ensures session keys are ephemeral and discarded after use, with future sessions salted using pseudo-random values derived from past calls.
|
||||
- **Codec2 and 4FSK**: Voice data is compressed using Codec2 and modulated with 4FSK (Four Frequency Shift Keying) for transmission over GSM/LTE.
|
||||
- **Secure Contact Pairing**: Uses QR codes or VCF files for secure key exchange, preventing man-in-the-middle attacks.
|
||||
- **Encrypted DTMF**: Supports secure transmission of DTMF signals for authentication scenarios.
|
||||
- **Forward Error Correction (FEC)**: Detects up to 50% of transmission errors in Alpha 1, with plans for stronger FEC (>80% detection, 20% correction) in future iterations.
|
||||
- **Decentralized Design**: Operates without servers or third-party intermediaries, minimizing attack surfaces.
|
||||
- **Voice Authentication**: Implements cryptographic voice authentication to verify caller identity.
|
||||
|
||||
### Security Implementation
|
||||
- **Cryptographic Framework**: Uses ED25519 key pairs for authentication and encryption, generated and stored securely via the Android Keystore. The Noise protocol ensures secure key exchange and session setup. AES-256 and ECC (P-256, ECDH) are employed for data encryption.
|
||||
- **Analog Signal Encoding**: Codec2 compresses voice data, and 4FSK modulates encrypted data into the analog audio signal, ensuring compatibility with GSM/LTE networks.
|
||||
- **Threat Model**: Protects against eavesdropping, interception, replay attacks, and unauthorized access. Includes replay protection mechanisms and assumes adversaries may control network infrastructure but not device endpoints.
|
||||
- **Data Privacy**: Minimizes data storage (only encrypted keys and minimal metadata), with no unencrypted call metadata stored. Sensitive data is encrypted at rest.
|
||||
- **Current Status**: Protocol Alpha 1, tested in DryBox, validates peer ping, ephemeral key management, handshakes, real-time encryption, stream compression, and 4FSK transmission. Alpha 2 will enhance FEC and on-the-fly key exchange.
|
||||
|
||||
### Future Considerations
|
||||
- Develop a full RFC for the Icing protocol, documenting peer ping, handshakes, and FEC.
|
||||
- Optimize Codec2 and 4FSK for improved audio quality and transmission reliability.
|
||||
- Implement embedded silent data transmission (e.g., DTMF) and on-the-fly key exchange.
|
||||
- Enhance interoperability with existing standards (e.g., SIP, WebRTC).
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Developer
|
||||
|
||||
int main;
|
||||
The Icing Dialer and its protocol are open-source and extensible. This section explains the code architecture, Icing protocol library, and guidelines for integration into custom applications.
|
||||
|
||||
---
|
||||
### Code Architecture
|
||||
The Icing Dialer is developed in two implementations:
|
||||
- **Root-app**: For rooted Android devices, deployed via a Magisk module (~85% complete for Alpha 1).
|
||||
- **AOSP-app**: Integrated into a custom AOSP fork for native support, pending partnerships with AOSP-based projects (e.g., GrapheneOS, LineageOS).
|
||||
|
||||
The application is written in Kotlin, leveraging the Android Telephony API for call management and a modular architecture:
|
||||
- **UI Layer**: A responsive, intuitive interface resembling a default phone dialer, with accessibility features, contact management, and call history.
|
||||
- **Encryption Layer**: Manages ED25519 key pair generation (via Android Keystore or RAM for export), Noise protocol handshakes (XX and XK), Codec2 compression, and 4FSK modulation.
|
||||
- **Network Layer**: Interfaces with GSM/LTE networks via the Android Telephony API to encode encrypted data into analog audio signals.
|
||||
|
||||
### Icing Protocol Library
|
||||
The Kotlin-based Icing protocol library (~75% complete for Alpha 1) enables third-party applications to implement the Icing protocol. Key components include:
|
||||
- **KeyPairGenerator**: Generates and manages ED25519 key pairs, supporting secure (Android Keystore) and insecure (RAM) generation, with export/import capabilities.
|
||||
- **NoiseProtocolHandler**: Implements XX and XK handshakes for secure session establishment, ensuring Perfect Forward Secrecy.
|
||||
- **QRCodeHandler**: Manages secure contact sharing via QR codes or VCF files.
|
||||
- **AudioEncoder**: Compresses voice with Codec2 and modulates encrypted data with 4FSK.
|
||||
- **CallManager**: Uses the Android Telephony API to detect peers, initiate calls, and handle DTMF transmission.
|
||||
- **FECModule**: Implements basic FEC for detecting 50% of transmission errors, with plans for enhanced detection and correction.
|
||||
|
||||
#### Integration Guide
|
||||
1. **Include the Library**: Add the Icing protocol library to your project (available upon Beta release).
|
||||
2. **Initialize KeyPairGenerator**: Generate an ED25519 key pair using Android Keystore for secure storage or RAM for exportable keys.
|
||||
3. **Implement NoiseProtocolHandler**: Configure XX or XK handshakes for authentication and session setup.
|
||||
4. **Integrate QRCodeHandler**: Enable contact sharing via QR codes or VCF files.
|
||||
5. **Use AudioEncoder**: Compress voice with Codec2 and modulate with 4FSK for GSM/LTE transmission.
|
||||
6. **Leverage CallManager**: Manage calls and DTMF via the Android Telephony API.
|
||||
7. **Test with DryBox**: Validate implementation using the Python-based DryBox environment for end-to-end call simulation.
|
||||
|
||||
### Development Status
|
||||
- **Protocol Alpha 1**: Implemented in DryBox, supporting peer ping, ephemeral keys, handshakes, encryption, Codec2 compression, and 4FSK modulation.
|
||||
- **Kotlin Library**: 75% complete, with tasks remaining for Alpha 1 completion.
|
||||
- **Root-app**: 85% complete, with Magisk deployment in progress.
|
||||
- **AOSP-app**: In development, pending AOSP fork partnerships.
|
||||
|
||||
Developers can join our Reddit or Telegram communities for updates and to contribute to the open-source project.
|
||||
|
||||
---
|
||||
|
||||
## Manual Tests
|
||||
|
||||
1. Call grandpa
|
||||
2. Receive mum call
|
||||
3. Order 150g of 95% pure Bolivian coke without encryption
|
||||
4. Order again but with encryption
|
||||
5. Compare results
|
||||
The Icing project employs a rigorous manual testing policy to ensure the reliability, security, and usability of the Icing Dialer and its protocol. This section outlines our testing approach, incorporating beta testing scenarios and evaluation criteria.
|
||||
|
||||
### Testing Environment
|
||||
- **DryBox**: A Python-based environment simulating end-to-end encrypted calls over a controlled network, used to validate Protocol Alpha 1 and future iterations.
|
||||
- **Root-app Testing**: Conducted on rooted Android devices using Magisk modules.
|
||||
- **AOSP-app Testing**: Planned for custom AOSP forks, pending partnerships.
|
||||
|
||||
### Manual Testing Policy
|
||||
- **Usability Testing**: Beta testers evaluate the dialer’s intuitiveness as a drop-in replacement, testing call initiation, contact management, and voicemail. Initial tests confirm usability without prior instructions.
|
||||
- **Encryption Validation**: Tests in DryBox verify end-to-end encryption using Noise protocol (XX/XK handshakes), ED25519 key pairs, Codec2, and 4FSK. Includes encrypted DTMF and FEC (50% error detection).
|
||||
- **Contact Pairing**: Tests QR code and VCF-based contact sharing for security and functionality.
|
||||
- **Call Scenarios**: Tests include clear calls (to non-Icing and Icing dialers), encrypted calls (to known and unknown contacts), and DTMF transmission.
|
||||
- **Performance Testing**: Ensures minimal latency, low bandwidth usage, and high audio quality (clarity, minimal distortion) via Codec2/4FSK.
|
||||
- **Privacy Testing**: Verifies encrypted storage of keys and minimal metadata, with no unencrypted call logs stored.
|
||||
- **Integration Testing**: Validates Android Telephony API integration, permissions (microphone, camera, contacts), and background operation.
|
||||
|
||||
### Beta Testing Scenarios
|
||||
- Clear call from Icing Dialer to another dialer (e.g., Google, Apple).
|
||||
- Clear call between two Icing Dialers.
|
||||
- Clear call to a known contact (with public key) without Icing Dialer.
|
||||
- Encrypted call to a known contact with Icing Dialer.
|
||||
- Encrypted call to an unknown contact with Icing Dialer (optional, under development).
|
||||
- Create/edit/save contacts with/without public keys.
|
||||
- Share/import contacts via QR code/VCF.
|
||||
- Listen to voicemail and verify encryption.
|
||||
- Record and verify encrypted call integrity.
|
||||
- Change default SIM.
|
||||
|
||||
### Evaluation Criteria
|
||||
- **Security**: Validates AES-256/ECC encryption, ED25519 key management, Perfect Forward Secrecy, replay protection, and end-to-end encryption integrity.
|
||||
- **Performance**: Measures call setup latency, bandwidth efficiency, and audio quality (clarity, consistency).
|
||||
- **Usability**: Ensures intuitive UI, seamless call handling, and robust error recovery.
|
||||
- **Interoperability**: Tests compatibility with GSM/LTE networks and potential future integration with SIP/WebRTC.
|
||||
- **Privacy**: Confirms encrypted data storage, minimal permissions, and no unencrypted metadata.
|
||||
- **Maintainability**: Reviews code quality, modularity, and documentation for the protocol and library.
|
||||
|
||||
### Current Testing Status
|
||||
- **Protocol Alpha 1**: Validated in DryBox for encryption, handshakes, Codec2/4FSK, and FEC.
|
||||
- **Root-app**: 85% complete, undergoing usability, performance, and security testing.
|
||||
- **Feedback Channels**: Anonymous feedback via CryptPad and FAQs on Reddit/Telegram inform testing.
|
||||
|
||||
### Future Testing Plans
|
||||
- Test Protocol Alpha 2 for enhanced FEC and on-the-fly key exchange.
|
||||
- Conduct AOSP-app testing with partnered forks.
|
||||
- Incorporate NPS/CSAT metrics from AMAs to assess user satisfaction.
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Icing Dialer and its protocol offer a pioneering approach to secure telephony, leveraging ED25519 key pairs, the Noise protocol, Codec2, 4FSK, and the Android Telephony API. This manual provides comprehensive guidance for users, security experts, and developers, supported by a robust testing policy. For further details or to contribute, visit our Reddit or Telegram communities.
|
||||
|
0
gonogo-review/KPI_Report.md
Normal file
0
gonogo-review/KPI_Report.md
Normal file
69
gonogo-review/Roadmap_PGE-5.md
Normal file
69
gonogo-review/Roadmap_PGE-5.md
Normal file
@ -0,0 +1,69 @@
|
||||
# Context
|
||||
|
||||
## Glossary
|
||||
|
||||
- DryBox: Our python test environment, simulating a call Initiator and Responder using the protocol through a controlled cellular network.
|
||||
- Root-app: A version of the application only installable on rooted Android devices.
|
||||
- AOSP-app: A version of the application imbedded in an AOSP fork, coming with an AOSP install.
|
||||
- AOSP: Android Open Source Project. The bare-bone of Android maintained by google, currently Open-Source, and forked by many. GrapheneOS, LineageOS, and /e/ are examples of AOSP forks.
|
||||
- Magisk module: Magisk is the reference open-source Android rooting tool, a Magisk Module is a script leveraging Magisk's root tools to install or tweak a specific app or setting with special permissions.
|
||||
- Alpha 1, 2, Beta: We plan two iterations of Alpha development, leading towards a final Beta protocol and root/AOSP applications release.
|
||||
|
||||
## Current status:
|
||||
|
||||
Protocol Alpha 1 => DryBox
|
||||
|
||||
App => 85% done
|
||||
|
||||
Kotlin Lib => 75% Alpha 1 done
|
||||
|
||||
Partnerships => Communication engaged
|
||||
|
||||
## Remaining steps:
|
||||
|
||||
- Design and test Protocol Alpha 2
|
||||
- Finish Alpha 1 Lib's implementation
|
||||
- Implement Root-app Alpha 1
|
||||
- Streamline Root-app tests
|
||||
- Develop at least one AOSP fork partnership
|
||||
- AOSP forks' local AOSP-app implementation
|
||||
- AOSP forks' partnership AOSP-app implementation
|
||||
- Magisk Root-app module self-hosted or official deployment
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Plan
|
||||
|
||||
Our plan is defined with monthly granularity.
|
||||
|
||||
Every month, a recap will be published on Telegram and Reddit about the achieved goals, suprises, and feeling on next month's goal.
|
||||
|
||||
- ### September & October
|
||||
|
||||
- Finish Alpha 1 lib's and Root-app implementation
|
||||
- App features & UI improvements
|
||||
- Streamlined Root-app tests
|
||||
|
||||
- ### November
|
||||
|
||||
- Magisk Root-app module
|
||||
- Drybox features improvements
|
||||
- Alpha 2 theory and Drybox testing
|
||||
|
||||
- ### December
|
||||
|
||||
- AOSP forks' local app implementation (AOSP-app)
|
||||
- App features & UI improvements
|
||||
- IF APPLICABLE: AOSP fork real partnership (i.e GrapheneOS, LineageOS, /e/, etc...)
|
||||
|
||||
- ### January
|
||||
|
||||
- Root-app & AOSP-app Alpha 2 implementation
|
||||
- Entire code audit and Beta Release preparation
|
||||
- Enhancement from "Alpha 2" to Beta
|
||||
|
||||
- ### February
|
||||
|
||||
- Website and community Beta Release preparation
|
||||
- Official Beta Release event: Root-app and AOSP-app APK Alpha APK publication
|
BIN
gonogo-review/Roadmap_PGE-5.pdf
Normal file
BIN
gonogo-review/Roadmap_PGE-5.pdf
Normal file
Binary file not shown.
253
protocol_prototype/DryBox/UI/audio_player.py
Normal file
253
protocol_prototype/DryBox/UI/audio_player.py
Normal file
@ -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
|
220
protocol_prototype/DryBox/UI/audio_processor.py
Normal file
220
protocol_prototype/DryBox/UI/audio_processor.py
Normal file
@ -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
|
714
protocol_prototype/DryBox/UI/main.py
Normal file
714
protocol_prototype/DryBox/UI/main.py
Normal file
@ -0,0 +1,714 @@
|
||||
import sys
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QFrame, QSizePolicy, QStyle, QTextEdit, QSplitter,
|
||||
QMenu, QAction, QInputDialog, QShortcut
|
||||
)
|
||||
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("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; }
|
||||
QPushButton {
|
||||
background-color: #0078D4; color: white; border: none;
|
||||
padding: 10px 15px; border-radius: 5px; font-size: 14px;
|
||||
min-height: 30px;
|
||||
}
|
||||
QPushButton:hover { background-color: #005A9E; }
|
||||
QPushButton:pressed { background-color: #003C6B; }
|
||||
QPushButton#settingsButton { background-color: #555555; }
|
||||
QPushButton#settingsButton:hover { background-color: #777777; }
|
||||
QFrame#phoneDisplay {
|
||||
background-color: #1E1E1E; border: 2px solid #0078D4;
|
||||
border-radius: 10px;
|
||||
}
|
||||
QLabel#phoneTitleLabel {
|
||||
font-size: 18px; font-weight: bold; padding-bottom: 5px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
QLabel#mainTitleLabel {
|
||||
font-size: 24px; font-weight: bold; color: #00A2E8;
|
||||
padding: 15px;
|
||||
}
|
||||
QWidget#phoneWidget {
|
||||
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 with splitter
|
||||
main_widget = QWidget()
|
||||
self.setCentralWidget(main_widget)
|
||||
main_layout = QVBoxLayout()
|
||||
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("Integrated Protocol Control Panel")
|
||||
app_title_label.setObjectName("mainTitleLabel")
|
||||
app_title_label.setAlignment(Qt.AlignCenter)
|
||||
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(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, 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)
|
||||
# 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()
|
||||
|
||||
# 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.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)
|
||||
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(10)
|
||||
phone_layout.setContentsMargins(15, 15, 15, 15)
|
||||
phone_container_widget.setLayout(phone_layout)
|
||||
|
||||
phone_title_label = QLabel(title)
|
||||
phone_title_label.setObjectName("phoneTitleLabel")
|
||||
phone_title_label.setAlignment(Qt.AlignCenter)
|
||||
phone_layout.addWidget(phone_title_label)
|
||||
|
||||
phone_display_frame = QFrame()
|
||||
phone_display_frame.setObjectName("phoneDisplay")
|
||||
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)
|
||||
phone_status_label = QLabel("Idle")
|
||||
phone_status_label.setAlignment(Qt.AlignCenter)
|
||||
phone_status_label.setFont(QFont("Arial", 16))
|
||||
display_content_layout.addWidget(phone_status_label)
|
||||
phone_layout.addWidget(phone_display_frame, alignment=Qt.AlignCenter)
|
||||
|
||||
phone_button = QPushButton()
|
||||
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")
|
||||
waveform_label.setAlignment(Qt.AlignCenter)
|
||||
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")
|
||||
sent_waveform_label.setAlignment(Qt.AlignCenter)
|
||||
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, playback_button, record_button
|
||||
|
||||
def update_phone_ui(self, phone_id):
|
||||
phone = self.manager.phones[phone_id]
|
||||
other_phone = self.manager.phones[1 - phone_id]
|
||||
state = phone['state']
|
||||
phone_number = other_phone['number'] if state != PhoneState.IDLE else ""
|
||||
button = phone['button']
|
||||
status_label = phone['status_label']
|
||||
|
||||
if state == PhoneState.IDLE:
|
||||
button.setText("Call")
|
||||
button.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
|
||||
status_label.setText("Idle")
|
||||
button.setStyleSheet("background-color: #0078D4;")
|
||||
elif state == PhoneState.CALLING:
|
||||
button.setText("Cancel")
|
||||
button.setIcon(self.style().standardIcon(QStyle.SP_MediaStop))
|
||||
status_label.setText(f"Calling {phone_number}...")
|
||||
button.setStyleSheet("background-color: #E81123;")
|
||||
elif state == PhoneState.IN_CALL:
|
||||
button.setText("Hang Up")
|
||||
button.setIcon(self.style().standardIcon(QStyle.SP_DialogCancelButton))
|
||||
status_label.setText(f"In Call with {phone_number}")
|
||||
button.setStyleSheet("background-color: #E81123;")
|
||||
elif state == PhoneState.RINGING:
|
||||
button.setText("Answer")
|
||||
button.setIcon(self.style().standardIcon(QStyle.SP_DialogApplyButton))
|
||||
status_label.setText(f"Incoming Call from {phone_number}")
|
||||
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]
|
||||
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:
|
||||
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":
|
||||
# Old text-based handshake trigger - no longer used
|
||||
self.debug(f"Phone {client_id + 1} received legacy HANDSHAKE message")
|
||||
elif number == "HANDSHAKE_DONE":
|
||||
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()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication(sys.argv)
|
||||
window = PhoneUI()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
127
protocol_prototype/DryBox/UI/noise_wrapper.py
Normal file
127
protocol_prototype/DryBox/UI/noise_wrapper.py
Normal file
@ -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")
|
374
protocol_prototype/DryBox/UI/phone_manager.py
Normal file
374
protocol_prototype/DryBox/UI/phone_manager.py
Normal file
@ -0,0 +1,374 @@
|
||||
import secrets
|
||||
from PyQt5.QtCore import QTimer
|
||||
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 = 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,
|
||||
'client': client,
|
||||
'state': PhoneState.IDLE,
|
||||
'number': "123-4567" if i == 0 else "987-6543",
|
||||
'audio_timer': None,
|
||||
'keypair': keypair,
|
||||
'public_key': keypair.public,
|
||||
'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']
|
||||
|
||||
def phone_action(self, phone_id, ui_manager):
|
||||
phone = self.phones[phone_id]
|
||||
other_phone = self.phones[1 - phone_id]
|
||||
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:
|
||||
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:
|
||||
phone['state'] = other_phone['state'] = PhoneState.IDLE
|
||||
phone['client'].send("CALL_END")
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
|
||||
def start_audio(self, client_id, parent=None):
|
||||
self.handshake_done_count += 1
|
||||
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:
|
||||
# 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":
|
||||
return PhoneState.RINGING
|
||||
elif state_str in ["CALL_END", "CALL_DROPPED"]:
|
||||
return PhoneState.IDLE
|
||||
elif state_str == "IN_CALL":
|
||||
return PhoneState.IN_CALL
|
||||
elif state_str == "HANDSHAKE":
|
||||
return PhoneState.IN_CALL
|
||||
elif state_str == "HANDSHAKE_DONE":
|
||||
return PhoneState.IN_CALL
|
||||
return PhoneState.IDLE
|
7
protocol_prototype/DryBox/UI/phone_state.py
Normal file
7
protocol_prototype/DryBox/UI/phone_state.py
Normal file
@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
class PhoneState(Enum):
|
||||
IDLE = 0
|
||||
CALLING = 1
|
||||
IN_CALL = 2
|
||||
RINGING = 3
|
133
protocol_prototype/DryBox/UI/protocol_client_state.py
Normal file
133
protocol_prototype/DryBox/UI/protocol_client_state.py
Normal file
@ -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)
|
456
protocol_prototype/DryBox/UI/protocol_phone_client.py
Normal file
456
protocol_prototype/DryBox/UI/protocol_phone_client.py
Normal file
@ -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)
|
@ -1,415 +0,0 @@
|
||||
import sys
|
||||
import random
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QFrame, QSizePolicy, QStyle
|
||||
)
|
||||
from PyQt5.QtCore import Qt, QTimer, QSize, QPointF, pyqtSignal, QThread
|
||||
from PyQt5.QtGui import QPainter, QColor, QPen, QLinearGradient, QBrush, QIcon, QFont
|
||||
|
||||
# --- Phone Client Thread ---
|
||||
class PhoneClient(QThread):
|
||||
data_received = pyqtSignal(bytes, int) # Include client_id
|
||||
state_changed = pyqtSignal(str, str, int) # Include client_id
|
||||
|
||||
def __init__(self, host, port, client_id):
|
||||
super().__init__()
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.client_id = client_id
|
||||
self.sock = None
|
||||
self.running = True
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||||
self.sock.settimeout(15)
|
||||
self.sock.connect((self.host, self.port))
|
||||
print(f"Client {self.client_id} connected to {self.host}:{self.port}")
|
||||
while self.running:
|
||||
try:
|
||||
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
|
||||
decoded_data = data.decode('utf-8', errors='ignore').strip()
|
||||
print(f"Client {self.client_id} received raw: {decoded_data}")
|
||||
if decoded_data in ["RINGING", "CALL_END", "CALL_DROPPED", "IN_CALL"]:
|
||||
self.state_changed.emit(decoded_data, "", self.client_id)
|
||||
else:
|
||||
self.data_received.emit(data, self.client_id)
|
||||
print(f"Client {self.client_id} received audio: {decoded_data}")
|
||||
except socket.timeout:
|
||||
print(f"Client {self.client_id} timed out waiting for data")
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Client {self.client_id} error: {e}")
|
||||
self.state_changed.emit("CALL_END", "", self.client_id)
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Client {self.client_id} connection failed: {e}")
|
||||
finally:
|
||||
if self.sock:
|
||||
self.sock.close()
|
||||
|
||||
def send(self, message):
|
||||
if self.sock and self.running:
|
||||
try:
|
||||
self.sock.send(message.encode())
|
||||
print(f"Client {self.client_id} sent: {message}")
|
||||
except Exception as e:
|
||||
print(f"Client {self.client_id} send error: {e}")
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
if self.sock:
|
||||
self.sock.close()
|
||||
|
||||
# --- Custom Waveform Widget ---
|
||||
class WaveformWidget(QWidget):
|
||||
def __init__(self, parent=None, dynamic=False):
|
||||
super().__init__(parent)
|
||||
self.dynamic = dynamic
|
||||
self.setMinimumSize(200, 80)
|
||||
self.setMaximumHeight(100)
|
||||
self.waveform_data = [random.randint(10, 90) for _ in range(50)]
|
||||
if self.dynamic:
|
||||
self.timer = QTimer(self)
|
||||
self.timer.timeout.connect(self.update_waveform)
|
||||
self.timer.start(100)
|
||||
|
||||
def update_waveform(self):
|
||||
self.waveform_data = self.waveform_data[1:] + [random.randint(10, 90)]
|
||||
self.update()
|
||||
|
||||
def set_data(self, data):
|
||||
amplitude = sum(byte for byte in data) % 90 + 10
|
||||
self.waveform_data = self.waveform_data[1:] + [amplitude]
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
painter.fillRect(self.rect(), QColor("#2D2D2D"))
|
||||
gradient = QLinearGradient(0, 0, 0, self.height())
|
||||
gradient.setColorAt(0.0, QColor("#0078D4"))
|
||||
gradient.setColorAt(1.0, QColor("#50E6A4"))
|
||||
pen = QPen(QBrush(gradient), 2)
|
||||
painter.setPen(pen)
|
||||
bar_width = self.width() / len(self.waveform_data)
|
||||
max_h = self.height() - 10
|
||||
for i, val in enumerate(self.waveform_data):
|
||||
bar_height = (val / 100.0) * max_h
|
||||
x = i * bar_width
|
||||
y = (self.height() - bar_height) / 2
|
||||
painter.drawLine(QPointF(x + bar_width / 2, y), QPointF(x + bar_width / 2, y + bar_height))
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self.update()
|
||||
|
||||
# --- Phone State ---
|
||||
class PhoneState:
|
||||
IDLE = 0
|
||||
CALLING = 1
|
||||
IN_CALL = 2
|
||||
RINGING = 3
|
||||
|
||||
class PhoneUI(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Enhanced Dual Phone Interface")
|
||||
self.setGeometry(100, 100, 900, 750)
|
||||
self.setStyleSheet("""
|
||||
QMainWindow { background-color: #333333; }
|
||||
QLabel { color: #E0E0E0; font-size: 14px; }
|
||||
QPushButton {
|
||||
background-color: #0078D4; color: white; border: none;
|
||||
padding: 10px 15px; border-radius: 5px; font-size: 14px;
|
||||
min-height: 30px;
|
||||
}
|
||||
QPushButton:hover { background-color: #005A9E; }
|
||||
QPushButton:pressed { background-color: #003C6B; }
|
||||
QPushButton#settingsButton { background-color: #555555; }
|
||||
QPushButton#settingsButton:hover { background-color: #777777; }
|
||||
QFrame#phoneDisplay {
|
||||
background-color: #1E1E1E; border: 2px solid #0078D4;
|
||||
border-radius: 10px;
|
||||
}
|
||||
QLabel#phoneTitleLabel {
|
||||
font-size: 18px; font-weight: bold; padding-bottom: 5px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
QLabel#mainTitleLabel {
|
||||
font-size: 24px; font-weight: bold; color: #00A2E8;
|
||||
padding: 15px;
|
||||
}
|
||||
QWidget#phoneWidget {
|
||||
border: 1px solid #4A4A4A; border-radius: 8px;
|
||||
padding: 10px; background-color: #3A3A3A;
|
||||
}
|
||||
""")
|
||||
|
||||
# Phone states
|
||||
self.phone1_state = PhoneState.IDLE
|
||||
self.phone2_state = PhoneState.IDLE
|
||||
|
||||
# Phone clients
|
||||
self.phone1_client = PhoneClient("localhost", 12345, 0)
|
||||
self.phone2_client = PhoneClient("localhost", 12345, 1)
|
||||
self.phone1_client.data_received.connect(lambda data, cid: self.update_waveform(cid, data))
|
||||
self.phone2_client.data_received.connect(lambda data, cid: self.update_waveform(cid, data))
|
||||
self.phone1_client.state_changed.connect(lambda state, num, cid: self.set_phone_state(cid, self.map_state(state), num))
|
||||
self.phone2_client.state_changed.connect(lambda state, num, cid: self.set_phone_state(cid, self.map_state(state), num))
|
||||
self.phone1_client.start()
|
||||
self.phone2_client.start()
|
||||
|
||||
# Main widget and layout
|
||||
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)
|
||||
|
||||
# App Title
|
||||
app_title_label = QLabel("Dual Phone Control Panel")
|
||||
app_title_label.setObjectName("mainTitleLabel")
|
||||
app_title_label.setAlignment(Qt.AlignCenter)
|
||||
main_layout.addWidget(app_title_label)
|
||||
|
||||
# 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 1
|
||||
phone1_widget_container, self.phone1_display, self.phone1_button, self.phone1_waveform = self._create_phone_ui("Phone 1", self.phone1_action)
|
||||
phone_controls_layout.addWidget(phone1_widget_container)
|
||||
|
||||
# Phone 2
|
||||
phone2_widget_container, self.phone2_display, self.phone2_button, self.phone2_waveform = self._create_phone_ui("Phone 2", self.phone2_action)
|
||||
phone_controls_layout.addWidget(phone2_widget_container)
|
||||
|
||||
# Spacer
|
||||
main_layout.addStretch(1)
|
||||
|
||||
# Settings Button
|
||||
self.settings_button = QPushButton("Settings")
|
||||
self.settings_button.setObjectName("settingsButton")
|
||||
self.settings_button.setFixedWidth(180)
|
||||
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)
|
||||
|
||||
# Initialize button states
|
||||
self._update_phone_button_ui(self.phone1_button, self.phone1_state)
|
||||
self._update_phone_button_ui(self.phone2_button, self.phone2_state)
|
||||
|
||||
def _create_phone_ui(self, title, action_slot):
|
||||
phone_container_widget = QWidget()
|
||||
phone_container_widget.setObjectName("phoneWidget")
|
||||
phone_layout = QVBoxLayout()
|
||||
phone_layout.setAlignment(Qt.AlignCenter)
|
||||
phone_layout.setSpacing(15)
|
||||
phone_container_widget.setLayout(phone_layout)
|
||||
|
||||
phone_title_label = QLabel(title)
|
||||
phone_title_label.setObjectName("phoneTitleLabel")
|
||||
phone_title_label.setAlignment(Qt.AlignCenter)
|
||||
phone_layout.addWidget(phone_title_label)
|
||||
|
||||
phone_display_frame = QFrame()
|
||||
phone_display_frame.setObjectName("phoneDisplay")
|
||||
phone_display_frame.setFixedSize(250, 350)
|
||||
phone_display_frame.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
|
||||
display_content_layout = QVBoxLayout(phone_display_frame)
|
||||
display_content_layout.setAlignment(Qt.AlignCenter)
|
||||
phone_status_label = QLabel("Idle")
|
||||
phone_status_label.setAlignment(Qt.AlignCenter)
|
||||
phone_status_label.setFont(QFont("Arial", 16))
|
||||
display_content_layout.addWidget(phone_status_label)
|
||||
phone_layout.addWidget(phone_display_frame, alignment=Qt.AlignCenter)
|
||||
|
||||
phone_button = QPushButton()
|
||||
phone_button.setFixedWidth(120)
|
||||
phone_button.setIconSize(QSize(20, 20))
|
||||
phone_button.clicked.connect(action_slot)
|
||||
phone_layout.addWidget(phone_button, alignment=Qt.AlignCenter)
|
||||
|
||||
waveform_label = QLabel(f"{title} Audio")
|
||||
waveform_label.setAlignment(Qt.AlignCenter)
|
||||
waveform_label.setStyleSheet("font-size: 14px; color: #E0E0E0;")
|
||||
phone_layout.addWidget(waveform_label)
|
||||
waveform_widget = WaveformWidget(dynamic=False)
|
||||
phone_layout.addWidget(waveform_widget, alignment=Qt.AlignCenter)
|
||||
|
||||
phone_display_frame.setProperty("statusLabel", phone_status_label)
|
||||
return phone_container_widget, phone_display_frame, phone_button, waveform_widget
|
||||
|
||||
def _update_phone_button_ui(self, button, state, phone_number=""):
|
||||
parent_widget = button.parentWidget()
|
||||
if parent_widget:
|
||||
frame = parent_widget.findChild(QFrame, "phoneDisplay")
|
||||
if frame:
|
||||
status_label = frame.property("statusLabel")
|
||||
if status_label:
|
||||
if state == PhoneState.IDLE:
|
||||
button.setText("Call")
|
||||
button.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
|
||||
status_label.setText("Idle")
|
||||
button.setStyleSheet("background-color: #0078D4;")
|
||||
elif state == PhoneState.CALLING:
|
||||
button.setText("Cancel")
|
||||
button.setIcon(self.style().standardIcon(QStyle.SP_MediaStop))
|
||||
status_label.setText(f"Calling {phone_number}...")
|
||||
button.setStyleSheet("background-color: #E81123;")
|
||||
elif state == PhoneState.IN_CALL:
|
||||
button.setText("Hang Up")
|
||||
button.setIcon(self.style().standardIcon(QStyle.SP_DialogCancelButton))
|
||||
status_label.setText(f"In Call with {phone_number}")
|
||||
button.setStyleSheet("background-color: #E81123;")
|
||||
elif state == PhoneState.RINGING:
|
||||
button.setText("Answer")
|
||||
button.setIcon(self.style().standardIcon(QStyle.SP_DialogApplyButton))
|
||||
status_label.setText(f"Incoming Call from {phone_number}")
|
||||
button.setStyleSheet("background-color: #107C10;")
|
||||
else:
|
||||
print("Warning: statusLabel property not found")
|
||||
else:
|
||||
print("Warning: QFrame not found")
|
||||
else:
|
||||
print("Warning: Parent widget not found")
|
||||
|
||||
def update_waveform(self, client_id, data):
|
||||
print(f"Updating waveform for client_id {client_id}")
|
||||
waveform = self.phone1_waveform if client_id == 0 else self.phone2_waveform
|
||||
waveform.set_data(data)
|
||||
|
||||
def map_state(self, state_str):
|
||||
if state_str == "RINGING":
|
||||
return PhoneState.RINGING
|
||||
elif state_str in ["CALL_END", "CALL_DROPPED"]:
|
||||
return PhoneState.IDLE
|
||||
elif state_str == "IN_CALL":
|
||||
return PhoneState.IN_CALL
|
||||
return PhoneState.IDLE # Default to IDLE
|
||||
|
||||
def set_phone_state(self, client_id, state, number=""):
|
||||
if client_id == 0:
|
||||
self.phone1_state = state
|
||||
self._update_phone_button_ui(self.phone1_button, self.phone1_state, number if number else "123-4567")
|
||||
if state == PhoneState.IDLE and hasattr(self, 'phone1_audio_timer'):
|
||||
self.phone1_audio_timer.stop()
|
||||
elif state == PhoneState.IN_CALL and (not hasattr(self, 'phone1_audio_timer') or not self.phone1_audio_timer.isActive()):
|
||||
self.phone1_audio_timer = QTimer(self)
|
||||
self.phone1_audio_timer.timeout.connect(self.send_phone1_audio)
|
||||
self.phone1_audio_timer.start(1000)
|
||||
else:
|
||||
self.phone2_state = state
|
||||
self._update_phone_button_ui(self.phone2_button, self.phone2_state, number if number else "987-6543")
|
||||
if state == PhoneState.IDLE and hasattr(self, 'phone2_audio_timer'):
|
||||
self.phone2_audio_timer.stop()
|
||||
elif state == PhoneState.IN_CALL and (not hasattr(self, 'phone2_audio_timer') or not self.phone2_audio_timer.isActive()):
|
||||
self.phone2_audio_timer = QTimer(self)
|
||||
self.phone2_audio_timer.timeout.connect(self.send_phone2_audio)
|
||||
self.phone2_audio_timer.start(1000)
|
||||
|
||||
def phone1_action(self):
|
||||
print("Phone 1 Action")
|
||||
if self.phone1_state == PhoneState.IDLE:
|
||||
self.phone1_state = PhoneState.CALLING
|
||||
self.phone1_client.send("RINGING")
|
||||
self._update_phone_button_ui(self.phone1_button, self.phone1_state, "123-4567")
|
||||
elif self.phone1_state == PhoneState.CALLING:
|
||||
self.phone1_state = PhoneState.IDLE
|
||||
self.phone1_client.send("CALL_END")
|
||||
self._update_phone_button_ui(self.phone1_button, self.phone1_state)
|
||||
if hasattr(self, 'phone1_audio_timer'):
|
||||
self.phone1_audio_timer.stop()
|
||||
elif self.phone1_state == PhoneState.RINGING:
|
||||
self.phone1_state = PhoneState.IN_CALL
|
||||
self.phone2_state = PhoneState.IN_CALL # Sync both phones
|
||||
self.phone1_client.send("IN_CALL")
|
||||
self._update_phone_button_ui(self.phone1_button, self.phone1_state, "123-4567")
|
||||
self._update_phone_button_ui(self.phone2_button, self.phone2_state, "987-6543")
|
||||
# Start audio timer
|
||||
self.phone1_audio_timer = QTimer(self)
|
||||
self.phone1_audio_timer.timeout.connect(self.send_phone1_audio)
|
||||
self.phone1_audio_timer.start(1000)
|
||||
elif self.phone1_state == PhoneState.IN_CALL:
|
||||
self.phone1_state = PhoneState.IDLE
|
||||
self.phone2_state = PhoneState.IDLE # Sync both phones
|
||||
self.phone1_client.send("CALL_END")
|
||||
self._update_phone_button_ui(self.phone1_button, self.phone1_state)
|
||||
self._update_phone_button_ui(self.phone2_button, self.phone2_state)
|
||||
if hasattr(self, 'phone1_audio_timer'):
|
||||
self.phone1_audio_timer.stop()
|
||||
|
||||
def send_phone1_audio(self):
|
||||
if self.phone1_state == PhoneState.IN_CALL:
|
||||
message = f"Audio packet {random.randint(1, 1000)}"
|
||||
self.phone1_client.send(message)
|
||||
|
||||
def phone2_action(self):
|
||||
print("Phone 2 Action")
|
||||
if self.phone2_state == PhoneState.IDLE:
|
||||
self.phone2_state = PhoneState.CALLING
|
||||
self.phone2_client.send("RINGING")
|
||||
self._update_phone_button_ui(self.phone2_button, self.phone2_state, "987-6543")
|
||||
elif self.phone2_state == PhoneState.CALLING:
|
||||
self.phone2_state = PhoneState.IDLE
|
||||
self.phone2_client.send("CALL_END")
|
||||
self._update_phone_button_ui(self.phone2_button, self.phone2_state)
|
||||
if hasattr(self, 'phone2_audio_timer'):
|
||||
self.phone2_audio_timer.stop()
|
||||
elif self.phone2_state == PhoneState.RINGING:
|
||||
self.phone2_state = PhoneState.IN_CALL
|
||||
self.phone1_state = PhoneState.IN_CALL # Sync both phones
|
||||
self.phone2_client.send("IN_CALL")
|
||||
self._update_phone_button_ui(self.phone2_button, self.phone2_state, "987-6543")
|
||||
self._update_phone_button_ui(self.phone1_button, self.phone1_state, "123-4567")
|
||||
# Start audio timer
|
||||
self.phone2_audio_timer = QTimer(self)
|
||||
self.phone2_audio_timer.timeout.connect(self.send_phone2_audio)
|
||||
self.phone2_audio_timer.start(1000)
|
||||
elif self.phone2_state == PhoneState.IN_CALL:
|
||||
self.phone2_state = PhoneState.IDLE
|
||||
self.phone1_state = PhoneState.IDLE # Sync both phones
|
||||
self.phone2_client.send("CALL_END")
|
||||
self._update_phone_button_ui(self.phone2_button, self.phone2_state)
|
||||
self._update_phone_button_ui(self.phone1_button, self.phone1_state)
|
||||
if hasattr(self, 'phone2_audio_timer'):
|
||||
self.phone2_audio_timer.stop()
|
||||
|
||||
def send_phone2_audio(self):
|
||||
if self.phone2_state == PhoneState.IN_CALL:
|
||||
message = f"Audio packet {random.randint(1, 1000)}"
|
||||
self.phone2_client.send(message)
|
||||
|
||||
def settings_action(self):
|
||||
print("Settings clicked")
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.phone1_client.stop()
|
||||
self.phone2_client.stop()
|
||||
event.accept()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication(sys.argv)
|
||||
window = PhoneUI()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
196
protocol_prototype/DryBox/UI/session.py
Normal file
196
protocol_prototype/DryBox/UI/session.py
Normal file
@ -0,0 +1,196 @@
|
||||
import socket
|
||||
import logging
|
||||
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
|
||||
|
||||
# Configure logging - disabled by default to avoid noise
|
||||
# logging.basicConfig(level=logging.DEBUG, format="%(message)s")
|
||||
|
||||
class NoiseXKSession:
|
||||
@staticmethod
|
||||
def generate_keypair() -> KeyPair:
|
||||
"""
|
||||
Generate a static X25519 KeyPair.
|
||||
Returns:
|
||||
KeyPair object with .private and .public attributes.
|
||||
"""
|
||||
return X25519DH().generate_keypair()
|
||||
|
||||
def __init__(self, local_kp: KeyPair, peer_pubkey: PublicKey):
|
||||
"""
|
||||
Initialize with our KeyPair and the peer's PublicKey.
|
||||
"""
|
||||
self.local_kp: KeyPair = local_kp
|
||||
self.peer_pubkey: PublicKey = peer_pubkey
|
||||
|
||||
# Build the Noise handshake state (X25519 DH, ChaChaPoly cipher, SHA256 hash)
|
||||
cipher = ChaChaPolyCipher()
|
||||
dh = X25519DH()
|
||||
hshash = SHA256Hash()
|
||||
symmetric = SymmetricState(CipherState(cipher), hshash)
|
||||
self._hs = HandshakeState(symmetric, dh)
|
||||
|
||||
self._send_cs = None # type: CipherState
|
||||
self._recv_cs = None
|
||||
|
||||
def handshake(self, sock: socket.socket, initiator: bool) -> None:
|
||||
"""
|
||||
Perform the XK handshake over the socket. Branches on initiator/responder
|
||||
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})")
|
||||
# initialize with our KeyPair and their PublicKey
|
||||
if initiator:
|
||||
# initiator knows peer’s static out-of-band
|
||||
self._hs.initialize(
|
||||
XKHandshakePattern(),
|
||||
True,
|
||||
b'',
|
||||
s=self.local_kp,
|
||||
rs=self.peer_pubkey
|
||||
)
|
||||
else:
|
||||
# logging.debug("[handshake] responder initializing without rs")
|
||||
# responder must NOT supply rs here
|
||||
self._hs.initialize(
|
||||
XKHandshakePattern(),
|
||||
False,
|
||||
b'',
|
||||
s=self.local_kp
|
||||
)
|
||||
|
||||
cs_pair = None
|
||||
if initiator:
|
||||
# 1) -> e
|
||||
buf1 = bytearray()
|
||||
cs_pair = self._hs.write_message(b'', buf1)
|
||||
# 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()}")
|
||||
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()}")
|
||||
self._send_all(sock, buf3)
|
||||
else:
|
||||
# 1) <- e
|
||||
msg1 = self._recv_all(sock)
|
||||
# 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()}")
|
||||
self._send_all(sock, buf2)
|
||||
|
||||
# 3) <- se (final)
|
||||
msg3 = self._recv_all(sock)
|
||||
# logging.debug(f"[<- se] {msg3.hex()}")
|
||||
cs_pair = self._hs.read_message(msg3, bytearray())
|
||||
|
||||
# on the final step, we must get exactly two CipherStates
|
||||
if not cs_pair or len(cs_pair) != 2:
|
||||
raise RuntimeError("Handshake did not complete properly")
|
||||
cs0, cs1 = cs_pair
|
||||
# the library returns (cs_encrypt_for_initiator, cs_decrypt_for_initiator)
|
||||
if initiator:
|
||||
# initiator: cs0 encrypts, cs1 decrypts
|
||||
self._send_cs, self._recv_cs = cs0, cs1
|
||||
else:
|
||||
# responder must swap
|
||||
self._send_cs, self._recv_cs = cs1, cs0
|
||||
|
||||
# dump the raw symmetric keys & nonces (if available)
|
||||
self._dump_cipherstate("HANDSHAKE→ SEND", self._send_cs)
|
||||
self._dump_cipherstate("HANDSHAKE→ RECV", self._recv_cs)
|
||||
|
||||
def send(self, sock: socket.socket, plaintext: bytes) -> None:
|
||||
"""
|
||||
Encrypt and send a message.
|
||||
"""
|
||||
if self._send_cs is None:
|
||||
raise RuntimeError("Handshake not complete")
|
||||
ct = self._send_cs.encrypt_with_ad(b'', plaintext)
|
||||
logging.debug(f"[ENCRYPT] {ct.hex()}")
|
||||
# self._dump_cipherstate("SEND→ after encrypt", self._send_cs)
|
||||
self._send_all(sock, ct)
|
||||
|
||||
def receive(self, sock: socket.socket) -> bytes:
|
||||
"""
|
||||
Receive and decrypt a message.
|
||||
"""
|
||||
if self._recv_cs is None:
|
||||
raise RuntimeError("Handshake not complete")
|
||||
ct = self._recv_all(sock)
|
||||
logging.debug(f"[CIPHERTEXT] {ct.hex()}")
|
||||
# self._dump_cipherstate("RECV→ before decrypt", self._recv_cs)
|
||||
pt = self._recv_cs.decrypt_with_ad(b'', ct)
|
||||
logging.debug(f"[DECRYPT] {pt!r}")
|
||||
return pt
|
||||
|
||||
def decrypt(self, ciphertext: bytes) -> bytes:
|
||||
"""
|
||||
Decrypt a ciphertext received as bytes.
|
||||
"""
|
||||
if self._recv_cs is None:
|
||||
raise RuntimeError("Handshake not complete")
|
||||
# Remove 2-byte length prefix if present
|
||||
if len(ciphertext) >= 2 and int.from_bytes(ciphertext[:2], 'big') == len(ciphertext) - 2:
|
||||
logging.debug(f"[DECRYPT] Stripping 2-byte length prefix from {len(ciphertext)}-byte input")
|
||||
ciphertext = ciphertext[2:]
|
||||
logging.debug(f"[CIPHERTEXT] {ciphertext.hex()}")
|
||||
# self._dump_cipherstate("DECRYPT→ before decrypt", self._recv_cs)
|
||||
pt = self._recv_cs.decrypt_with_ad(b'', ciphertext)
|
||||
logging.debug(f"[DECRYPT] {pt!r}")
|
||||
return pt
|
||||
|
||||
def _send_all(self, sock: socket.socket, data: bytes) -> None:
|
||||
# Length-prefix (2 bytes big-endian) + data
|
||||
length = len(data).to_bytes(2, 'big')
|
||||
logging.debug(f"[SEND] length={length.hex()}, data={data.hex()}")
|
||||
sock.sendall(length + data)
|
||||
|
||||
def _recv_all(self, sock: socket.socket) -> bytes:
|
||||
# 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()})")
|
||||
data = self._read_exact(sock, length)
|
||||
# logging.debug(f"[RECV] data={data.hex()}")
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _read_exact(sock: socket.socket, n: int) -> bytes:
|
||||
buf = bytearray()
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError("Socket closed during read")
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
|
||||
def _dump_cipherstate(self, label: str, cs: CipherState) -> None:
|
||||
"""
|
||||
Print the symmetric key (cs._k) and nonce counter (cs._n) for inspection.
|
||||
"""
|
||||
key = cs._key
|
||||
nonce = getattr(cs, "_n", None)
|
||||
if isinstance(key, (bytes, bytearray)):
|
||||
key_hex = key.hex()
|
||||
else:
|
||||
key_hex = repr(key)
|
||||
logging.debug(f"[{label}] key={key_hex}")
|
67
protocol_prototype/DryBox/UI/waveform_widget.py
Normal file
67
protocol_prototype/DryBox/UI/waveform_widget.py
Normal file
@ -0,0 +1,67 @@
|
||||
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
|
||||
|
||||
class WaveformWidget(QWidget):
|
||||
def __init__(self, parent=None, dynamic=False):
|
||||
super().__init__(parent)
|
||||
self.dynamic = dynamic
|
||||
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)
|
||||
self.timer.timeout.connect(self.update_waveform)
|
||||
self.timer.start(100)
|
||||
|
||||
def update_waveform(self):
|
||||
self.waveform_data = self.waveform_data[1:] + [random.randint(10, 90)]
|
||||
self.update()
|
||||
|
||||
def set_data(self, data):
|
||||
# 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):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
painter.fillRect(self.rect(), QColor("#2D2D2D"))
|
||||
gradient = QLinearGradient(0, 0, 0, self.height())
|
||||
gradient.setColorAt(0.0, QColor("#0078D4"))
|
||||
gradient.setColorAt(1.0, QColor("#50E6A4"))
|
||||
pen = QPen(QBrush(gradient), 2)
|
||||
painter.setPen(pen)
|
||||
bar_width = self.width() / len(self.waveform_data)
|
||||
max_h = self.height() - 10
|
||||
for i, val in enumerate(self.waveform_data):
|
||||
bar_height = (val / 100.0) * max_h
|
||||
x = i * bar_width
|
||||
y = (self.height() - bar_height) / 2
|
||||
painter.drawLine(QPointF(x + bar_width / 2, y), QPointF(x + bar_width / 2, y + bar_height))
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self.update()
|
@ -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', 5555))
|
||||
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()
|
@ -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', 5555))
|
||||
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()
|
@ -1,59 +0,0 @@
|
||||
#gsm_simulator.py
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 12345
|
||||
FRAME_SIZE = 1000
|
||||
FRAME_DELAY = 0.02
|
||||
|
||||
clients = []
|
||||
|
||||
def handle_client(client_sock, client_id):
|
||||
print(f"Starting handle_client for Client {client_id}")
|
||||
while True:
|
||||
try:
|
||||
other_client = clients[1 - client_id] if len(clients) == 2 else None
|
||||
print(f"Client {client_id} waiting for data, other_client exists: {other_client is not None}")
|
||||
data = client_sock.recv(1024)
|
||||
if not data:
|
||||
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}")
|
||||
except Exception as e:
|
||||
print(f"Error with Client {client_id}: {e}")
|
||||
break
|
||||
print(f"Closing connection for Client {client_id}")
|
||||
client_sock.close()
|
||||
|
||||
def start_simulator():
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind((HOST, PORT))
|
||||
server.listen(2)
|
||||
print(f"GSM Simulator listening on {HOST}:{PORT}...")
|
||||
|
||||
while len(clients) < 2:
|
||||
client_sock, addr = server.accept()
|
||||
client_sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) # Keep connection alive
|
||||
clients.append(client_sock)
|
||||
client_id = len(clients) - 1
|
||||
print(f"Client {client_id} connected from {addr}")
|
||||
threading.Thread(target=handle_client, args=(client_sock, client_id), daemon=True).start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("Shutting down simulator...")
|
||||
for client in clients:
|
||||
client.close()
|
||||
server.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
start_simulator()
|
Binary file not shown.
58
protocol_prototype/DryBox/install_audio_deps.sh
Executable file
58
protocol_prototype/DryBox/install_audio_deps.sh
Executable file
@ -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
|
@ -1,86 +0,0 @@
|
||||
import socket
|
||||
import os
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
# Configuration
|
||||
HOST = "localhost"
|
||||
PORT = 12345
|
||||
INPUT_FILE = "input.wav"
|
||||
OUTPUT_FILE = "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"))
|
22
protocol_prototype/DryBox/requirements.txt
Normal file
22
protocol_prototype/DryBox/requirements.txt
Normal file
@ -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
|
11
protocol_prototype/DryBox/run_ui.sh
Executable file
11
protocol_prototype/DryBox/run_ui.sh
Executable file
@ -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
|
110
protocol_prototype/DryBox/simulator/gsm_simulator.py
Normal file
110
protocol_prototype/DryBox/simulator/gsm_simulator.py
Normal file
@ -0,0 +1,110 @@
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import struct
|
||||
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 12345
|
||||
FRAME_SIZE = 10000 # Increased to avoid fragmenting voice frames
|
||||
FRAME_DELAY = 0.02
|
||||
|
||||
clients = []
|
||||
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]
|
||||
|
||||
try:
|
||||
chunk = client_sock.recv(4096)
|
||||
if not chunk:
|
||||
print(f"Client {client_id} disconnected or no data received")
|
||||
break
|
||||
|
||||
# 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
|
||||
finally:
|
||||
print(f"Closing connection for Client {client_id}")
|
||||
with clients_lock:
|
||||
if client_id < len(clients) and clients[client_id] == client_sock:
|
||||
clients.pop(client_id)
|
||||
print(f"Removed Client {client_id} from clients list")
|
||||
client_sock.close()
|
||||
|
||||
def start_simulator():
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind((HOST, PORT))
|
||||
server.listen(2)
|
||||
print(f"GSM Simulator listening on {HOST}:{PORT}...")
|
||||
|
||||
try:
|
||||
while True:
|
||||
client_sock, addr = server.accept()
|
||||
client_sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) # Keep connection alive
|
||||
with clients_lock:
|
||||
if len(clients) < 2:
|
||||
clients.append(client_sock)
|
||||
client_id = len(clients) - 1
|
||||
else:
|
||||
# Replace a closed socket or reject if both slots are active
|
||||
replaced = False
|
||||
for i in range(len(clients)):
|
||||
if clients[i].fileno() == -1: # Check if socket is closed
|
||||
clients[i] = client_sock
|
||||
client_id = i
|
||||
replaced = True
|
||||
break
|
||||
if not replaced:
|
||||
print(f"Rejecting new client from {addr}: max clients (2) reached")
|
||||
client_sock.close()
|
||||
continue
|
||||
print(f"Client {client_id} connected from {addr}")
|
||||
threading.Thread(target=handle_client, args=(client_sock, client_id), daemon=True).start()
|
||||
except KeyboardInterrupt:
|
||||
print("Shutting down simulator...")
|
||||
finally:
|
||||
with clients_lock:
|
||||
for client in clients:
|
||||
client.close()
|
||||
clients.clear()
|
||||
server.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
start_simulator()
|
@ -5,7 +5,8 @@
|
||||
# Variables
|
||||
IMAGE_NAME="gsm-simulator"
|
||||
CONTAINER_NAME="gsm-sim"
|
||||
PORT="5555"
|
||||
PORT="12345"
|
||||
LOG_FILE="gsm_simulator.log"
|
||||
|
||||
# Check if Docker is installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
@ -13,7 +14,7 @@ if ! command -v docker &> /dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the gsm_simulator.py file exists in the current directory
|
||||
# Check if gsm_simulator.py exists
|
||||
if [ ! -f "gsm_simulator.py" ]; then
|
||||
echo "Error: gsm_simulator.py not found in the current directory."
|
||||
echo "Please ensure gsm_simulator.py is present and try again."
|
||||
@ -26,11 +27,16 @@ if [ ! -f "Dockerfile" ]; then
|
||||
cat <<EOF > Dockerfile
|
||||
FROM python:3.9-slim
|
||||
WORKDIR /app
|
||||
COPY gsm_simulator.py /app
|
||||
COPY gsm_simulator.py .
|
||||
EXPOSE 12345
|
||||
CMD ["python", "gsm_simulator.py"]
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Ensure log file is writable
|
||||
touch $LOG_FILE
|
||||
chmod 666 $LOG_FILE
|
||||
|
||||
# Build the Docker image
|
||||
echo "Building Docker image: $IMAGE_NAME..."
|
||||
docker build -t $IMAGE_NAME .
|
||||
@ -41,7 +47,7 @@ if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop and remove any existing container with the same name
|
||||
# Stop and remove any existing container
|
||||
if [ "$(docker ps -q -f name=$CONTAINER_NAME)" ]; then
|
||||
echo "Stopping existing container: $CONTAINER_NAME..."
|
||||
docker stop $CONTAINER_NAME
|
||||
@ -51,16 +57,12 @@ if [ "$(docker ps -aq -f name=$CONTAINER_NAME)" ]; then
|
||||
docker rm $CONTAINER_NAME
|
||||
fi
|
||||
|
||||
# Run the Docker container
|
||||
echo "Launching GSM Simulator in Docker container: $CONTAINER_NAME..."
|
||||
docker run -d -p $PORT:$PORT --name $CONTAINER_NAME $IMAGE_NAME
|
||||
# Clean up dangling images
|
||||
docker image prune -f
|
||||
|
||||
# Check if the container is running
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "GSM Simulator is running on port $PORT."
|
||||
echo "Container ID: $(docker ps -q -f name=$CONTAINER_NAME)"
|
||||
echo "You can now connect your external Python programs to localhost:$PORT."
|
||||
else
|
||||
echo "Error: Failed to launch the container."
|
||||
exit 1
|
||||
fi
|
||||
# Run the Docker container interactively
|
||||
echo "Launching GSM Simulator in Docker container: $CONTAINER_NAME..."
|
||||
docker run -it --rm -p $PORT:$PORT --name $CONTAINER_NAME $IMAGE_NAME | tee $LOG_FILE
|
||||
|
||||
# Note: Script will block here until container exits
|
||||
echo "GSM Simulator stopped. Logs saved to $LOG_FILE."
|
714
protocol_prototype/DryBox/voice_codec.py
Normal file
714
protocol_prototype/DryBox/voice_codec.py
Normal file
@ -0,0 +1,714 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# Quiet initialization - no print
|
||||
|
||||
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('<HH', energy_int, zc_int)
|
||||
|
||||
# Pad to expected frame size
|
||||
data += b'\x00' * (self.frame_bytes - len(data))
|
||||
|
||||
return data[:self.frame_bytes]
|
||||
|
||||
def _simulate_decompression(self, compressed: bytes):
|
||||
"""Simulate Codec2 decompression (for testing)."""
|
||||
# Unpack features
|
||||
if len(compressed) >= 4:
|
||||
energy, zero_crossings = struct.unpack('<HH', compressed[:4])
|
||||
else:
|
||||
energy, zero_crossings = 1000, 100
|
||||
|
||||
# Generate synthetic speech-like signal
|
||||
if HAS_NUMPY:
|
||||
t = np.linspace(0, self.frame_ms/1000, self.frame_samples)
|
||||
|
||||
# Base frequency from zero crossings
|
||||
freq = zero_crossings * 10 # Simplified mapping
|
||||
|
||||
# Generate harmonics
|
||||
signal = np.zeros(self.frame_samples)
|
||||
for harmonic in range(1, 4):
|
||||
signal += np.sin(2 * np.pi * freq * harmonic * t) / harmonic
|
||||
|
||||
# Apply energy envelope
|
||||
signal *= energy / 10000.0
|
||||
|
||||
# Convert to 16-bit PCM
|
||||
return (signal * 32767).astype(np.int16)
|
||||
else:
|
||||
# Manual generation without numpy
|
||||
samples = []
|
||||
freq = zero_crossings * 10
|
||||
|
||||
for i in range(self.frame_samples):
|
||||
t = i / 8000.0 # 8kHz sample rate
|
||||
value = 0
|
||||
for harmonic in range(1, 4):
|
||||
value += math.sin(2 * math.pi * freq * harmonic * t) / harmonic
|
||||
|
||||
value *= energy / 10000.0
|
||||
# Clamp to 16-bit range
|
||||
sample = int(value * 32767)
|
||||
sample = max(-32768, min(32767, sample))
|
||||
samples.append(sample)
|
||||
|
||||
return array.array('h', samples)
|
||||
|
||||
|
||||
class FSKModem:
|
||||
"""
|
||||
4-FSK modem for transmitting digital data over voice channels.
|
||||
Designed to survive GSM/AMR/EVS vocoders.
|
||||
"""
|
||||
|
||||
def __init__(self, sample_rate: int = 8000, baud_rate: int = 600):
|
||||
"""
|
||||
Initialize FSK modem.
|
||||
|
||||
Args:
|
||||
sample_rate: Audio sample rate (Hz)
|
||||
baud_rate: Symbol rate (baud)
|
||||
"""
|
||||
self.sample_rate = sample_rate
|
||||
self.baud_rate = baud_rate
|
||||
self.samples_per_symbol = int(sample_rate / baud_rate)
|
||||
|
||||
# 4-FSK frequencies (300-3400 Hz band)
|
||||
self.frequencies = [
|
||||
600, # 00
|
||||
1200, # 01
|
||||
1800, # 10
|
||||
2400 # 11
|
||||
]
|
||||
|
||||
# Preamble for synchronization (800 Hz, 100ms)
|
||||
self.preamble_freq = 800
|
||||
self.preamble_duration = 0.1 # seconds
|
||||
|
||||
# Quiet initialization - no print
|
||||
|
||||
def modulate(self, data: bytes, add_preamble: bool = True):
|
||||
"""
|
||||
Modulate binary data to FSK audio signal.
|
||||
|
||||
Args:
|
||||
data: Binary data to modulate
|
||||
add_preamble: Whether to add synchronization preamble
|
||||
|
||||
Returns:
|
||||
Audio signal (normalized float32 array or list)
|
||||
"""
|
||||
# Convert bytes to dibits (2-bit symbols)
|
||||
symbols = []
|
||||
for byte in data:
|
||||
symbols.extend([
|
||||
(byte >> 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('<BIH',
|
||||
frame.mode,
|
||||
frame.frame_number,
|
||||
len(frame.bits)
|
||||
) + frame.bits
|
||||
|
||||
# Generate IV for this frame (ChaCha20 needs 16 bytes)
|
||||
iv = struct.pack('<Q', self.voice_iv_counter) + b'\x00' * 8 # 8 + 8 = 16 bytes
|
||||
self.voice_iv_counter += 1
|
||||
|
||||
# Encrypt using ChaCha20
|
||||
from encryption import chacha20_encrypt
|
||||
key = bytes.fromhex(self.protocol.hkdf_key)
|
||||
encrypted = chacha20_encrypt(frame_data, key, iv)
|
||||
|
||||
# Add sequence number and IV hint
|
||||
return struct.pack('<HQ', self.voice_sequence, self.voice_iv_counter) + encrypted
|
||||
|
||||
def _decrypt_voice_frame(self, data: bytes) -> Optional[Codec2Frame]:
|
||||
"""Decrypt a voice frame."""
|
||||
if len(data) < 10:
|
||||
return None
|
||||
|
||||
# Extract sequence and IV hint
|
||||
sequence, iv_hint = struct.unpack('<HQ', data[:10])
|
||||
encrypted = data[10:]
|
||||
|
||||
# Generate IV (16 bytes for ChaCha20)
|
||||
iv = struct.pack('<Q', iv_hint) + b'\x00' * 8
|
||||
|
||||
# Decrypt
|
||||
from encryption import chacha20_decrypt
|
||||
key = bytes.fromhex(self.protocol.hkdf_key)
|
||||
|
||||
try:
|
||||
decrypted = chacha20_decrypt(encrypted, key, iv)
|
||||
|
||||
# Parse frame
|
||||
mode, frame_num, bits_len = struct.unpack('<BIH', decrypted[:7])
|
||||
bits = decrypted[7:7+bits_len]
|
||||
|
||||
return Codec2Frame(
|
||||
mode=Codec2Mode(mode),
|
||||
bits=bits,
|
||||
timestamp=0, # Will be set by caller
|
||||
frame_number=frame_num
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"{RED}[VOICE]{RESET} Decryption failed: {e}")
|
||||
return None
|
||||
|
||||
def _add_fec(self, data: bytes) -> 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}")
|
BIN
protocol_prototype/DryBox/wav/input.wav
Normal file
BIN
protocol_prototype/DryBox/wav/input.wav
Normal file
Binary file not shown.
BIN
protocol_prototype/DryBox/wav/test_codec_only.wav
Normal file
BIN
protocol_prototype/DryBox/wav/test_codec_only.wav
Normal file
Binary file not shown.
BIN
protocol_prototype/DryBox/wav/test_full_pipeline.wav
Normal file
BIN
protocol_prototype/DryBox/wav/test_full_pipeline.wav
Normal file
Binary file not shown.
@ -1,4 +1,4 @@
|
||||
<mxfile host="app.diagrams.net" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" version="26.1.3" pages="2">
|
||||
<mxfile host="Electron" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/26.0.16 Chrome/132.0.6834.196 Electron/34.2.0 Safari/537.36" version="26.0.16" pages="4">
|
||||
<diagram id="C5RBs43oDa-KdzZeNtuy" name="Logique">
|
||||
<mxGraphModel dx="735" dy="407" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
@ -259,8 +259,16 @@
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
<diagram id="4Sb7mgJDpsadGym-U4wz" name="Echanges">
|
||||
<mxGraphModel dx="1195" dy="683" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||
<diagram id="c7L-flsM9ZWaCx455Pfy" name="Transport Layer - 0">
|
||||
<mxGraphModel dx="1434" dy="835" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
<diagram id="4Sb7mgJDpsadGym-U4wz" name="Protocol Layer - 0">
|
||||
<mxGraphModel dx="1434" dy="835" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
@ -287,7 +295,7 @@
|
||||
<mxCell id="n3lF8vaYaHAhAfaeaFZn-2" value="Version" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="305" y="130" width="58.75" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-1" value="(0-128)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="n3lF8vaYaHAhAfaeaFZn-2">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-1" value="(0-128)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="n3lF8vaYaHAhAfaeaFZn-2" vertex="1">
|
||||
<mxGeometry x="3.75" y="30" width="51.25" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="n3lF8vaYaHAhAfaeaFZn-4" value="Checksum" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
@ -506,61 +514,292 @@
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-16" value="= (180b ~ 212b) + yyy" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="465" y="1285" width="130" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-2" value="Cypher" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-2" value="Cypher" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="375" y="130" width="58.75" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-3" value="(0-16)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="pWkGvNQAXuiST1IiWYlx-2">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-3" value="(0-16)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="pWkGvNQAXuiST1IiWYlx-2" vertex="1">
|
||||
<mxGeometry x="3.75" y="30" width="51.25" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-4" value="4b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-4" value="4b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="375" y="210" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-5" value="Cypher" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-5" value="Cypher" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="600" y="290" width="58.75" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-6" value="(0-16)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="pWkGvNQAXuiST1IiWYlx-5">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-6" value="(0-16)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="pWkGvNQAXuiST1IiWYlx-5" vertex="1">
|
||||
<mxGeometry x="3.75" y="30" width="51.25" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-7" value="4b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-7" value="4b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="601.88" y="350" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-8" value="status" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-8" value="status" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="425" y="1170" width="65" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-9" value="CRC ?" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="pWkGvNQAXuiST1IiWYlx-8">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-9" value="CRC ?" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="pWkGvNQAXuiST1IiWYlx-8" vertex="1">
|
||||
<mxGeometry x="2.5" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-10" value="4b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-10" value="4b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="428.75" y="1230" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-11" value="iv" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-11" value="iv" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="505" y="1170" width="65" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-12" value="random<div>(+Z)</div>" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="pWkGvNQAXuiST1IiWYlx-11">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-12" value="random<div>(+Z)</div>" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="pWkGvNQAXuiST1IiWYlx-11" vertex="1">
|
||||
<mxGeometry x="2.5" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-13" value="BBB b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-13" value="BBB b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="193" y="1330" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-14" value="MAC" style="swimlane;whiteSpace=wrap;html=1;fillColor=#008a00;fontColor=#ffffff;strokeColor=#005700;" vertex="1" parent="1">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-14" value="MAC" style="swimlane;whiteSpace=wrap;html=1;fillColor=#008a00;fontColor=#ffffff;strokeColor=#005700;" parent="1" vertex="1">
|
||||
<mxGeometry x="286.13" y="1270" width="63.25" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-15" value="AEAD" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="pWkGvNQAXuiST1IiWYlx-14">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-15" value="AEAD" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="pWkGvNQAXuiST1IiWYlx-14" vertex="1">
|
||||
<mxGeometry x="1.6199999999999992" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-16" value="128b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-16" value="128b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="290.25" y="1330" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-17" value="Green = clear data" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#008a00;fontColor=#ffffff;strokeColor=#005700;" vertex="1" parent="1">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-17" value="Green = clear data" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#008a00;fontColor=#ffffff;strokeColor=#005700;" parent="1" vertex="1">
|
||||
<mxGeometry x="10" y="1170" width="110" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-18" value="<font style="color: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));">White = additional data</font>" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=none;strokeColor=light-dark(#6C8EBF,#FFFFFF);" vertex="1" parent="1">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-18" value="<font style="color: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));">White = additional data</font>" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=none;strokeColor=light-dark(#6C8EBF,#FFFFFF);" parent="1" vertex="1">
|
||||
<mxGeometry y="1220" width="130" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-19" value="Blue = encrypted data" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#0050ef;fontColor=#ffffff;strokeColor=#001DBC;" vertex="1" parent="1">
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-19" value="Blue = encrypted data" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#0050ef;fontColor=#ffffff;strokeColor=#001DBC;" parent="1" vertex="1">
|
||||
<mxGeometry x="10" y="1270" width="110" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
<diagram name="Protocol Layer - 1" id="_rkrwzJg5buKJxYS8faK">
|
||||
<mxGraphModel dx="1062" dy="1719" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-0" />
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-1" parent="0ZE-dMPOFneTTZtpNr96-0" />
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-2" value="" style="html=1;shadow=0;dashed=0;align=center;verticalAlign=middle;shape=mxgraph.arrows2.arrow;dy=0;dx=10;notch=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="160" y="120" width="260" height="120" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-3" value="<font style="color: rgb(255, 65, 27);"><b>ALICE&nbsp; (INITIATOR)</b></font>" style="shape=umlLifeline;perimeter=lifelinePerimeter;whiteSpace=wrap;html=1;container=1;dropTarget=0;collapsible=0;recursiveResize=0;outlineConnect=0;portConstraint=eastwest;newEdgeStyle={"curved":0,"rounded":0};participant=umlEntity;strokeWidth=2;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="120" y="40" width="40" height="3110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-4" value="<font>BOB</font>" style="shape=umlLifeline;perimeter=lifelinePerimeter;whiteSpace=wrap;html=1;container=1;dropTarget=0;collapsible=0;recursiveResize=0;outlineConnect=0;portConstraint=eastwest;newEdgeStyle={"curved":0,"rounded":0};participant=umlEntity;strokeWidth=2;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="690" y="40" width="40" height="3110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-5" value="PING" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=23;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="385" y="65" width="80" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-8" value="Version" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="171.25" y="130" width="58.75" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-9" value="(0-128)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-8">
|
||||
<mxGeometry x="3.75" y="30" width="51.25" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-12" value="" style="html=1;shadow=0;dashed=0;align=center;verticalAlign=middle;shape=mxgraph.arrows2.arrow;dy=0;dx=10;notch=0;rotation=-180;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="350" y="280" width="340" height="190" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-13" value="Timestamp" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="380" y="290" width="90" height="60" as="geometry">
|
||||
<mxRectangle x="210" y="130" width="80" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-14" value="timestamp" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;strokeWidth=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-13">
|
||||
<mxGeometry x="11.25" y="27.5" width="67.5" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-15" value="Version" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="479.76" y="290" width="58.75" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-16" value="0" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-15">
|
||||
<mxGeometry y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-19" value="Answer" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="548.76" y="290" width="57.5" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-20" value="<div>YES</div><div>NO<br></div>" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="533.76" y="315" width="53.75" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-21" value="HANDSHAKE" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=23;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="350" y="510" width="170" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-22" value="" style="html=1;shadow=0;dashed=0;align=center;verticalAlign=middle;shape=mxgraph.arrows2.arrow;dy=0;dx=10;notch=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="160" y="570" width="410" height="220" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-23" value="Clé éphémère" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="170" y="580" width="105" height="80" as="geometry">
|
||||
<mxRectangle x="210" y="130" width="80" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-24" value="Clé (publique) générée aléatoirement" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-23">
|
||||
<mxGeometry y="30" width="100" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-25" value="" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="285" y="580" width="105" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-29" value="" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="402.81" y="580" width="71.88" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-31" value="" style="html=1;shadow=0;dashed=0;align=center;verticalAlign=middle;shape=mxgraph.arrows2.arrow;dy=0;dx=10;notch=0;rotation=-180;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="285" y="830" width="410" height="180" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-32" value="Clé éphémère" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="305" y="840" width="105" height="80" as="geometry">
|
||||
<mxRectangle x="210" y="130" width="80" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-33" value="Clé (publique) générée aléatoirement" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-32">
|
||||
<mxGeometry y="30" width="100" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-34" value="" style="swimlane;whiteSpace=wrap;html=1;startSize=23;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="420" y="840" width="105" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-38" value="" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="537.81" y="840" width="71.88" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-40" value="Timestamp" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="182.5" y="690" width="80" height="70" as="geometry">
|
||||
<mxRectangle x="210" y="130" width="80" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-41" value="timestamp" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;strokeWidth=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-40">
|
||||
<mxGeometry x="6.25" y="32.5" width="67.5" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-42" value="Timestamp" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="606.25" y="930" width="80" height="70" as="geometry">
|
||||
<mxRectangle x="210" y="130" width="80" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-43" value="timestamp" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;strokeWidth=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-42">
|
||||
<mxGeometry x="6.25" y="32.5" width="67.5" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-44" value="" style="html=1;shadow=0;dashed=0;align=center;verticalAlign=middle;shape=mxgraph.arrows2.arrow;dy=0;dx=10;notch=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="160" y="1160" width="450" height="200" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-45" value="ENCRYPTED COMS" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=23;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="305" y="1100" width="240" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-47" value="7b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="170" y="210" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-49" value="= 48b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="350" y="210" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-50" value="16b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="393" y="350" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-51" value="7b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="479.13" y="350" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-52" value="1b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="545.51" y="350" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-54" value="= 32b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="465" y="395" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-55" value="264b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="193" y="660" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-56" value="512b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="307.5" y="660" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-57" value="256b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="409.38" y="660" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-59" value="16b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="192.5" y="760" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-60" value="=1096b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="315" y="750" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-61" value="=1096b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="327.5" y="970" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-66" value="nbretry" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="344.38" y="1170" width="65" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-67" value="y" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-66">
|
||||
<mxGeometry x="2.5" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-70" value="msg" style="swimlane;whiteSpace=wrap;html=1;fillColor=#0050ef;fontColor=#ffffff;strokeColor=#001DBC;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="187.5" y="1270" width="65" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-71" value="BBB" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-70">
|
||||
<mxGeometry x="2.5" y="30" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-73" value="8b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="349.38" y="1230" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-77" value="= (180b ~ 212b) + yyy" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="465" y="1285" width="130" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-78" value="Cypher" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="240" y="130" width="58.75" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-79" value="(0-32)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-78">
|
||||
<mxGeometry x="3.75" y="30" width="51.25" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-80" value="5b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="240" y="210" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-81" value="Cypher Offset" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="616.88" y="290" width="58.75" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-82" value="(0-8)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-81">
|
||||
<mxGeometry x="3.75" y="30" width="51.25" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-83" value="4b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="618.76" y="350" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-84" value="status" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="425" y="1170" width="65" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-85" value="CRC ?" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-84">
|
||||
<mxGeometry x="2.5" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-86" value="4b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="428.75" y="1230" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-89" value="BBB b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="193" y="1330" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-90" value="MAC" style="swimlane;whiteSpace=wrap;html=1;fillColor=#008a00;fontColor=#ffffff;strokeColor=#005700;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="286.13" y="1270" width="63.25" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-91" value="AEAD" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-90">
|
||||
<mxGeometry x="1.6199999999999992" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-92" value="128b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="290.25" y="1330" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-93" value="Green = clear data" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#008a00;fontColor=#ffffff;strokeColor=#005700;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="10" y="1170" width="110" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-94" value="<font style="color: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));">White = additional data</font>" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=none;strokeColor=light-dark(#6C8EBF,#FFFFFF);" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry y="1220" width="130" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="0ZE-dMPOFneTTZtpNr96-95" value="Blue = encrypted data" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#0050ef;fontColor=#ffffff;strokeColor=#001DBC;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="10" y="1270" width="110" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="el23RTDFL3Ay36EOE6FN-0" value="Quality" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="308.75" y="130" width="58.75" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="el23RTDFL3Ay36EOE6FN-1" value="(0-16)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="el23RTDFL3Ay36EOE6FN-0">
|
||||
<mxGeometry x="3.75" y="30" width="51.25" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="el23RTDFL3Ay36EOE6FN-2" value="4b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="308.75" y="210" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="el23RTDFL3Ay36EOE6FN-3" value="4b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="616.88" y="440" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="el23RTDFL3Ay36EOE6FN-4" value="Quality" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="616.88" y="380" width="58.75" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="el23RTDFL3Ay36EOE6FN-5" value="(0-16)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="el23RTDFL3Ay36EOE6FN-4">
|
||||
<mxGeometry x="3.75" y="30" width="51.25" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="1WmqBiAd3lf2sxgMsw2H-0" value="Best Case Scenario<div>Noise_XK_25519_ChaChaPoly_SHA256</div>" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=25;fontStyle=1" vertex="1" parent="0ZE-dMPOFneTTZtpNr96-1">
|
||||
<mxGeometry x="223" y="-20" width="405" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
|
91
protocol_prototype/Prototype/cli.py
Normal file
91
protocol_prototype/Prototype/cli.py
Normal file
@ -0,0 +1,91 @@
|
||||
import argparse
|
||||
import threading
|
||||
import sys
|
||||
|
||||
from noise_xk.session import NoiseXKSession
|
||||
from noise_xk.transport import P2PTransport
|
||||
from dissononce.dh.x25519.public import PublicKey
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(prog="noise_xk")
|
||||
parser.add_argument(
|
||||
"--listen-port", type=int, required=True,
|
||||
help="Port on which to bind+listen"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--peer-host", type=str, default="127.0.0.1",
|
||||
help="Peer host to dial (default: 127.0.0.1)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--peer-port", type=int, required=True,
|
||||
help="Peer port to dial"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# 1. Generate static keypair and print our static public key
|
||||
kp = NoiseXKSession.generate_keypair()
|
||||
# kp.public is a PublicKey; .data holds raw bytes
|
||||
local_priv = kp.private # carried implicitly in NoiseXKSession
|
||||
local_pub = kp.public
|
||||
print(f"[My static pubkey:] {local_pub.data.hex()}")
|
||||
|
||||
# 2. Read peer pubkey from user input
|
||||
peer_pubkey = None
|
||||
while True:
|
||||
line = input(">>> ").strip()
|
||||
if line.startswith("peer_pubkey "):
|
||||
hexstr = line.split(None, 1)[1]
|
||||
try:
|
||||
raw = bytes.fromhex(hexstr)
|
||||
peer_pubkey = PublicKey(raw) # wrap raw bytes in PublicKey
|
||||
break
|
||||
except ValueError:
|
||||
print("Invalid hex; please retry.")
|
||||
else:
|
||||
print("Use: peer_pubkey <hex>")
|
||||
|
||||
# 3. Establish P2P connection (race listen vs. dial)
|
||||
transport = P2PTransport(
|
||||
listen_port=args.listen_port,
|
||||
peer_host=args.peer_host,
|
||||
peer_port=args.peer_port
|
||||
)
|
||||
print(
|
||||
f"Racing connect/listen on ports "
|
||||
f"{args.listen_port} ⇆ {args.peer_host}:{args.peer_port}…"
|
||||
)
|
||||
sock, initiator = transport.connect()
|
||||
print(f"Connected (initiator={initiator}); performing handshake…")
|
||||
|
||||
# 4. Perform Noise XK handshake
|
||||
session = NoiseXKSession(kp, peer_pubkey)
|
||||
session.handshake(sock, initiator)
|
||||
print("Handshake complete! You can now type messages.")
|
||||
|
||||
# 5. Reader thread for incoming messages
|
||||
def reader():
|
||||
while True:
|
||||
try:
|
||||
pt = session.receive(sock)
|
||||
print(f"\n< {pt.decode()}")
|
||||
except Exception as e:
|
||||
print(f"\n[Receive error ({type(e).__name__}): {e!r}]")
|
||||
break
|
||||
|
||||
thread = threading.Thread(target=reader, daemon=True)
|
||||
thread.start()
|
||||
|
||||
# 6. Main loop: send user input
|
||||
try:
|
||||
for line in sys.stdin:
|
||||
text = line.rstrip("\n")
|
||||
if not text:
|
||||
continue
|
||||
session.send(sock, text.encode())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
179
protocol_prototype/Prototype/noise_xk/session.py
Normal file
179
protocol_prototype/Prototype/noise_xk/session.py
Normal file
@ -0,0 +1,179 @@
|
||||
# noise_xk/session.py
|
||||
|
||||
import socket
|
||||
import logging
|
||||
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
|
||||
|
||||
# Configure root logger for debug output
|
||||
logging.basicConfig(level=logging.DEBUG, format="%(message)s")
|
||||
|
||||
class NoiseXKSession:
|
||||
@staticmethod
|
||||
def generate_keypair() -> KeyPair:
|
||||
"""
|
||||
Generate a static X25519 KeyPair.
|
||||
Returns:
|
||||
KeyPair object with .private and .public attributes.
|
||||
"""
|
||||
return X25519DH().generate_keypair()
|
||||
|
||||
def __init__(self, local_kp: KeyPair, peer_pubkey: PublicKey):
|
||||
"""
|
||||
Initialize with our KeyPair and the peer's PublicKey.
|
||||
"""
|
||||
self.local_kp: KeyPair = local_kp
|
||||
self.peer_pubkey: PublicKey = peer_pubkey
|
||||
|
||||
# Build the Noise handshake state (X25519 DH, ChaChaPoly cipher, SHA256 hash)
|
||||
cipher = ChaChaPolyCipher()
|
||||
dh = X25519DH()
|
||||
hshash = SHA256Hash()
|
||||
symmetric = SymmetricState(CipherState(cipher), hshash)
|
||||
self._hs = HandshakeState(symmetric, dh)
|
||||
|
||||
self._send_cs = None # type: CipherState
|
||||
self._recv_cs = None
|
||||
|
||||
def handshake(self, sock: socket.socket, initiator: bool) -> None:
|
||||
"""
|
||||
Perform the XK handshake over the socket. Branches on initiator/responder
|
||||
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})")
|
||||
# initialize with our KeyPair and their PublicKey
|
||||
if initiator:
|
||||
# initiator knows peer’s static out-of-band
|
||||
self._hs.initialize(
|
||||
XKHandshakePattern(),
|
||||
True,
|
||||
b'',
|
||||
s=self.local_kp,
|
||||
rs=self.peer_pubkey
|
||||
)
|
||||
else:
|
||||
logging.debug("[handshake] responder initializing without rs")
|
||||
# responder must NOT supply rs here
|
||||
self._hs.initialize(
|
||||
XKHandshakePattern(),
|
||||
False,
|
||||
b'',
|
||||
s=self.local_kp
|
||||
)
|
||||
|
||||
cs_pair = None
|
||||
if initiator:
|
||||
# 1) -> e
|
||||
buf1 = bytearray()
|
||||
cs_pair = self._hs.write_message(b'', buf1)
|
||||
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()}")
|
||||
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()}")
|
||||
self._send_all(sock, buf3)
|
||||
else:
|
||||
# 1) <- e
|
||||
msg1 = self._recv_all(sock)
|
||||
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()}")
|
||||
self._send_all(sock, buf2)
|
||||
|
||||
# 3) <- se (final)
|
||||
msg3 = self._recv_all(sock)
|
||||
logging.debug(f"[<- se] {msg3.hex()}")
|
||||
cs_pair = self._hs.read_message(msg3, bytearray())
|
||||
|
||||
# on the final step, we must get exactly two CipherStates
|
||||
if not cs_pair or len(cs_pair) != 2:
|
||||
raise RuntimeError("Handshake did not complete properly")
|
||||
cs0, cs1 = cs_pair
|
||||
# the library returns (cs_encrypt_for_initiator, cs_decrypt_for_initiator)
|
||||
if initiator:
|
||||
# initiator: cs0 encrypts, cs1 decrypts
|
||||
self._send_cs, self._recv_cs = cs0, cs1
|
||||
else:
|
||||
# responder must swap
|
||||
self._send_cs, self._recv_cs = cs1, cs0
|
||||
|
||||
# dump the raw symmetric keys & nonces (if available)
|
||||
self._dump_cipherstate("HANDSHAKE→ SEND", self._send_cs)
|
||||
self._dump_cipherstate("HANDSHAKE→ RECV", self._recv_cs)
|
||||
|
||||
def send(self, sock: socket.socket, plaintext: bytes) -> None:
|
||||
"""
|
||||
Encrypt and send a message.
|
||||
"""
|
||||
if self._send_cs is None:
|
||||
raise RuntimeError("Handshake not complete")
|
||||
ct = self._send_cs.encrypt_with_ad(b'', plaintext)
|
||||
logging.debug(f"[ENCRYPT] {ct.hex()}")
|
||||
self._dump_cipherstate("SEND→ after encrypt", self._send_cs)
|
||||
self._send_all(sock, ct)
|
||||
|
||||
def receive(self, sock: socket.socket) -> bytes:
|
||||
"""
|
||||
Receive and decrypt a message.
|
||||
"""
|
||||
if self._recv_cs is None:
|
||||
raise RuntimeError("Handshake not complete")
|
||||
ct = self._recv_all(sock)
|
||||
logging.debug(f"[CIPHERTEXT] {ct.hex()}")
|
||||
self._dump_cipherstate("RECV→ before decrypt", self._recv_cs)
|
||||
pt = self._recv_cs.decrypt_with_ad(b'', ct)
|
||||
logging.debug(f"[DECRYPT] {pt!r}")
|
||||
return pt
|
||||
|
||||
def _send_all(self, sock: socket.socket, data: bytes) -> None:
|
||||
# Length-prefix (2 bytes big-endian) + data
|
||||
length = len(data).to_bytes(2, 'big')
|
||||
sock.sendall(length + data)
|
||||
|
||||
def _recv_all(self, sock: socket.socket) -> bytes:
|
||||
# Read 2-byte length prefix, then the payload
|
||||
hdr = self._read_exact(sock, 2)
|
||||
length = int.from_bytes(hdr, 'big')
|
||||
return self._read_exact(sock, length)
|
||||
|
||||
@staticmethod
|
||||
def _read_exact(sock: socket.socket, n: int) -> bytes:
|
||||
buf = bytearray()
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError("Socket closed during read")
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
|
||||
def _dump_cipherstate(self, label: str, cs: CipherState) -> None:
|
||||
"""
|
||||
Print the symmetric key (cs._k) and nonce counter (cs._n) for inspection.
|
||||
"""
|
||||
key = cs._key
|
||||
nonce = getattr(cs, "_n", None)
|
||||
if isinstance(key, (bytes, bytearray)):
|
||||
key_hex = key.hex()
|
||||
else:
|
||||
key_hex = repr(key)
|
||||
logging.debug(f"[{label}] key={key_hex}")
|
99
protocol_prototype/Prototype/noise_xk/transport.py
Normal file
99
protocol_prototype/Prototype/noise_xk/transport.py
Normal file
@ -0,0 +1,99 @@
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
class P2PTransport:
|
||||
def __init__(self, listen_port: int, peer_host: str, peer_port: int):
|
||||
"""
|
||||
Args:
|
||||
listen_port: port to bind() and accept()
|
||||
peer_host: host to dial()
|
||||
peer_port: port to dial()
|
||||
"""
|
||||
self.listen_port = listen_port
|
||||
self.peer_host = peer_host
|
||||
self.peer_port = peer_port
|
||||
|
||||
def connect(self) -> (socket.socket, bool):
|
||||
"""
|
||||
Race bind+listen vs. dial:
|
||||
- If dial succeeds first, return (sock, True) # we are initiator
|
||||
- If accept succeeds first, return (sock, False) # we are responder
|
||||
"""
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(('0.0.0.0', self.listen_port))
|
||||
server.listen(1)
|
||||
|
||||
result = {}
|
||||
event = threading.Event()
|
||||
lock = threading.Lock()
|
||||
|
||||
def accept_thread():
|
||||
try:
|
||||
conn, _ = server.accept()
|
||||
with lock:
|
||||
if not event.is_set():
|
||||
result['sock'] = conn
|
||||
result['initiator'] = False
|
||||
event.set()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def dial_thread():
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(1.0)
|
||||
while not event.is_set():
|
||||
try:
|
||||
sock.connect((self.peer_host, self.peer_port))
|
||||
with lock:
|
||||
if not event.is_set():
|
||||
result['sock'] = sock
|
||||
result['initiator'] = True
|
||||
event.set()
|
||||
return
|
||||
except (ConnectionRefusedError, socket.timeout):
|
||||
time.sleep(0.1)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
t1 = threading.Thread(target=accept_thread, daemon=True)
|
||||
t2 = threading.Thread(target=dial_thread, daemon=True)
|
||||
t1.start()
|
||||
t2.start()
|
||||
|
||||
event.wait()
|
||||
sock, initiator = result['sock'], result['initiator']
|
||||
# close the listening socket—we’ve got our P2P link
|
||||
server.close()
|
||||
# ensure this socket is in blocking mode (no lingering timeouts)
|
||||
sock.settimeout(None)
|
||||
return sock, initiator
|
||||
|
||||
def send_packet(self, sock: socket.socket, data: bytes) -> None:
|
||||
"""
|
||||
Send a 2-byte big-endian length prefix followed by data.
|
||||
"""
|
||||
length = len(data).to_bytes(2, 'big')
|
||||
sock.sendall(length + data)
|
||||
|
||||
def recv_packet(self, sock: socket.socket) -> bytes:
|
||||
"""
|
||||
Receive a 2-byte length prefix, then that many payload bytes.
|
||||
"""
|
||||
hdr = self._read_exact(sock, 2)
|
||||
length = int.from_bytes(hdr, 'big')
|
||||
return self._read_exact(sock, length)
|
||||
|
||||
@staticmethod
|
||||
def _read_exact(sock: socket.socket, n: int) -> bytes:
|
||||
buf = bytearray()
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError("Socket closed during read")
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
|
||||
def close(self, sock: socket.socket) -> None:
|
||||
sock.close()
|
@ -1,430 +0,0 @@
|
||||
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}")
|
@ -1,328 +0,0 @@
|
||||
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 <hex_pubkey> - Set peer identity public key")
|
||||
print(" connect <port> - 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 <index> <0|1> - Respond to a PING (0=reject, 1=accept)")
|
||||
print(" send_handshake - Send handshake with ephemeral keys")
|
||||
print(" generate_ecdhe <index> - Process handshake at specified index")
|
||||
print(" derive_hkdf - Derive encryption key using HKDF")
|
||||
print(" send_encrypted <plaintext> - Encrypt and send a message")
|
||||
print(" decrypt <index> - 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 <param> <value> - Configure auto mode parameters")
|
||||
print(" auto config list - Show all configurable parameters")
|
||||
print(" auto message <text> - 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 <index> - Display detailed information about a message in the queue")
|
||||
|
||||
print(f"\n{CYAN}Legacy Commands:{RESET}")
|
||||
print(" auto_responder <on|off> - 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 <hex_pubkey>")
|
||||
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 <port>")
|
||||
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 <index> <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 <index>")
|
||||
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 <plaintext>")
|
||||
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 <index>")
|
||||
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 <index>")
|
||||
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 <command> [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 <param> <value> 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 <param> <value>")
|
||||
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 <text>")
|
||||
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 <on|off>")
|
||||
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)
|
@ -1,165 +0,0 @@
|
||||
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)
|
@ -1,263 +0,0 @@
|
||||
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
|
@ -1,262 +0,0 @@
|
||||
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
|
||||
partial_val = (self.timestamp << (7+4+1)) | (self.version << (4+1)) | (self.cipher << 1) | self.answer
|
||||
partial_bytes = partial_val.to_bytes(6, 'big') # 6 bytes = 48 bits, 4 spare 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()
|
@ -1,815 +0,0 @@
|
||||
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
|
||||
)
|
||||
import transmission
|
||||
from encryption import (
|
||||
EncryptedMessage, MessageHeader,
|
||||
generate_iv, encrypt_message, decrypt_message
|
||||
)
|
||||
from auto_mode import AutoMode, AutoModeConfig
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
|
||||
# Determine pfs_param from first HANDSHAKE message (if any)
|
||||
pfs_param = None
|
||||
for msg in self.inbound_messages:
|
||||
if msg["type"] == "HANDSHAKE":
|
||||
try:
|
||||
handshake = msg["parsed"]
|
||||
pfs_param = handshake.pfs_hash
|
||||
except Exception:
|
||||
pfs_param = None
|
||||
break
|
||||
if pfs_param is None:
|
||||
print(f"{RED}[WARNING]{RESET} No HANDSHAKE found; using 32 zero bytes for pfs_param.")
|
||||
pfs_param = b"\x00" * 32 # 256-bit zeros
|
||||
|
||||
# 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("\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
|
7
protocol_prototype/requirements.txt
Normal file
7
protocol_prototype/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
# System install
|
||||
Docker
|
||||
Python3
|
||||
|
||||
# Venv install
|
||||
PyQt5
|
||||
dissononce
|
@ -1,100 +0,0 @@
|
||||
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
|
@ -2,10 +2,6 @@
|
||||
<a href="https://github.com/AlexisDanlos" target="_blank">Alexis DANLOS</a>
|
||||
{{end}}
|
||||
|
||||
{{define "ange"}}
|
||||
<a href="https://yw5n.com" target="_blank">Ange DUHAYON</a>
|
||||
{{end}}
|
||||
|
||||
{{define "bartosz"}}
|
||||
<a href="https://github.com/Bartoszkk" target="_blank">Bartosz MICHALAK</a>
|
||||
{{end}}
|
||||
|
Loading…
Reference in New Issue
Block a user