avalin proge

This commit is contained in:
MOJ1403 2026-03-23 19:29:24 +03:30
commit 2f128f9a1a
34 changed files with 4642 additions and 0 deletions

249
App_GUI.py Normal file
View File

@ -0,0 +1,249 @@
import customtkinter as ctk
# GUI Configuration
ctk.set_appearance_mode("Dark")
ctk.set_default_color_theme("blue")
class AppGUI(ctk.CTk):
def __init__(self, controller):
super().__init__()
self.controller = controller
self.title("Secure SMS - Raspberry Pi")
self.geometry("800x480") # Typical Pi touchscreen resolution
# Current state
self.current_contact = None
self.setup_ui()
self.load_contacts()
def setup_ui(self):
# Grid Layout (1x2) - Sidebar | Main Content
self.grid_rowconfigure(0, weight=1)
self.grid_columnconfigure(1, weight=1)
# --- Sidebar ---
self.sidebar_frame = ctk.CTkFrame(self, width=200, corner_radius=0)
self.sidebar_frame.grid(row=0, column=0, sticky="nsew")
self.sidebar_frame.grid_rowconfigure(4, weight=1)
self.logo_label = ctk.CTkLabel(self.sidebar_frame, text="Secure SMS", font=ctk.CTkFont(size=20, weight="bold"))
self.logo_label.grid(row=0, column=0, padx=20, pady=(20, 10))
self.contacts_scrollable_frame = ctk.CTkScrollableFrame(self.sidebar_frame, label_text="Contacts")
self.contacts_scrollable_frame.grid(row=1, column=0, padx=10, pady=10, sticky="nsew", rowspan=4)
# Settings / Add Contact Button
self.settings_btn = ctk.CTkButton(self.sidebar_frame, text="Settings / Add Contact", command=self.open_settings)
self.settings_btn.grid(row=5, column=0, padx=10, pady=20)
# --- Main Chat Area ---
self.main_frame = ctk.CTkFrame(self, corner_radius=0)
self.main_frame.grid(row=0, column=1, sticky="nsew")
self.main_frame.grid_rowconfigure(1, weight=1)
self.main_frame.grid_columnconfigure(0, weight=1)
# Chat Header
self.chat_header_var = ctk.StringVar(value="Select a contact")
self.chat_header = ctk.CTkLabel(self.main_frame, textvariable=self.chat_header_var, font=ctk.CTkFont(size=18, weight="bold"))
self.chat_header.grid(row=0, column=0, padx=20, pady=10, sticky="w")
# Secured Status Label Indicator
self.header_secure_status = ctk.CTkLabel(self.main_frame, text="Current Mode: Normal", text_color="yellow")
self.header_secure_status.grid(row=0, column=0, padx=20, pady=10, sticky="e")
# Messages Display
self.msg_display = ctk.CTkTextbox(self.main_frame, state="disabled", wrap="word")
self.msg_display.grid(row=1, column=0, padx=20, pady=(0, 10), sticky="nsew")
self.msg_display.tag_config('sent_normal', foreground='lightgray', justify='right')
self.msg_display.tag_config('sent_secure', foreground='lightgreen', justify='right')
self.msg_display.tag_config('recv_normal', foreground='white', justify='left')
self.msg_display.tag_config('recv_secure', foreground='mediumspringgreen', justify='left')
self.msg_display.tag_config('system', foreground='red', justify='center')
# Bottom Input Area
self.input_frame = ctk.CTkFrame(self.main_frame, height=50)
self.input_frame.grid(row=2, column=0, padx=20, pady=10, sticky="nsew")
self.input_frame.grid_columnconfigure(0, weight=1)
self.msg_entry = ctk.CTkEntry(self.input_frame, placeholder_text="Type a message...")
self.msg_entry.grid(row=0, column=0, padx=(10, 5), pady=10, sticky="nsew")
self.msg_entry.bind("<Return>", lambda e: self.send_message())
# Toggle Secure SMS
self.secure_mode_var = ctk.StringVar(value="off")
self.secure_switch = ctk.CTkSwitch(
self.input_frame,
text="Secure",
command=self.toggle_mode,
variable=self.secure_mode_var,
onvalue="on",
offvalue="off"
)
self.secure_switch.grid(row=0, column=1, padx=5, pady=10)
self.send_btn = ctk.CTkButton(self.input_frame, text="Send", width=80, command=self.send_message)
self.send_btn.grid(row=0, column=2, padx=(5, 10), pady=10)
def toggle_mode(self):
mode = "Secure" if self.secure_mode_var.get() == "on" else "Normal"
color = "mediumspringgreen" if mode == "Secure" else "yellow"
self.header_secure_status.configure(text=f"Current Mode: {mode}", text_color=color)
def load_contacts(self):
# Clear sidebar list
for widget in self.contacts_scrollable_frame.winfo_children():
widget.destroy()
contacts = self.controller.get_contacts()
for idx, contact in enumerate(contacts):
# contact = (name, phone, pubkey)
name, phone, _ = contact
btn = ctk.CTkButton(
self.contacts_scrollable_frame,
text=f"{name}\n{phone}",
command=lambda p=phone: self.select_contact(p),
fg_color="transparent",
border_width=2,
text_color=("gray10", "#DCE4EE")
)
btn.grid(row=idx, column=0, padx=5, pady=5, sticky="ew")
def select_contact(self, phone):
self.current_contact = self.controller.get_contact_info(phone)
name = self.current_contact[0]
self.chat_header_var.set(f"Chat with {name} ({phone})")
# Determine if we can do secure
has_key = self.current_contact[2] is not None
if not has_key:
self.secure_switch.deselect()
self.secure_switch.configure(state="disabled")
self.toggle_mode()
self.append_system_msg("This contact has no public key. Secure mode disabled.")
else:
self.secure_switch.configure(state="normal")
self.refresh_messages()
def refresh_messages(self):
self.msg_display.configure(state="normal")
self.msg_display.delete("1.0", "end")
if self.current_contact:
messages = self.controller.get_messages(self.current_contact[1])
for msg in messages:
# msg = (id, phone, text, date, is_secure, status)
text = msg[2]
date = msg[3]
is_secure = msg[4]
status = msg[5] # sent / recv
tag = f"{status}_{'secure' if is_secure else 'normal'}"
prefix = "🔒 " if is_secure else ""
display_text = f"[{date}]\n{prefix}{text}\n\n"
self.msg_display.insert("end", display_text, tag)
self.msg_display.configure(state="disabled")
self.msg_display.yview("end")
def append_system_msg(self, text):
self.msg_display.configure(state="normal")
self.msg_display.insert("end", f"--- {text} ---\n\n", "system")
self.msg_display.configure(state="disabled")
self.msg_display.yview("end")
def send_message(self):
if not self.current_contact:
return
text = self.msg_entry.get().strip()
if not text:
return
is_secure = self.secure_mode_var.get() == "on"
phone = self.current_contact[1]
# Ask controller to send
self.msg_entry.delete(0, 'end')
self.append_system_msg("Sending...")
self.update() # force UI refresh safely
success = self.controller.send_message(phone, text, is_secure)
if success:
self.refresh_messages()
else:
self.append_system_msg("Failed to send SMS!")
def open_settings(self):
SettingsWindow(self, self.controller)
class SettingsWindow(ctk.CTkToplevel):
def __init__(self, master, controller):
super().__init__(master)
self.controller = controller
self.title("Settings")
self.geometry("500x500")
# Bring to front
self.attributes('-topmost', 1)
self.grid_columnconfigure(0, weight=1)
# My Public Key
lbl1 = ctk.CTkLabel(self, text="My Public Key (Share this to chat securely):")
lbl1.grid(row=0, column=0, padx=20, pady=(20, 5), sticky="w")
self.my_key_box = ctk.CTkTextbox(self, height=100)
self.my_key_box.grid(row=1, column=0, padx=20, pady=5, sticky="ew")
self.my_key_box.insert("1.0", self.controller.get_my_public_key())
self.my_key_box.configure(state="disabled")
# Add / Update Contact
lbl_add = ctk.CTkLabel(self, text="Add/Update Contact:", font=ctk.CTkFont(weight="bold"))
lbl_add.grid(row=2, column=0, padx=20, pady=(30, 5), sticky="w")
self.name_entry = ctk.CTkEntry(self, placeholder_text="Contact Name")
self.name_entry.grid(row=3, column=0, padx=20, pady=5, sticky="ew")
self.phone_entry = ctk.CTkEntry(self, placeholder_text="Phone Number (+989...)")
self.phone_entry.grid(row=4, column=0, padx=20, pady=5, sticky="ew")
lbl2 = ctk.CTkLabel(self, text="Contact's Public Key (Optional but required for secure SMS):")
lbl2.grid(row=5, column=0, padx=20, pady=5, sticky="w")
self.contact_key_box = ctk.CTkTextbox(self, height=100)
self.contact_key_box.grid(row=6, column=0, padx=20, pady=5, sticky="ew")
self.save_btn = ctk.CTkButton(self, text="Save Contact", command=self.save_contact)
self.save_btn.grid(row=7, column=0, padx=20, pady=20)
def save_contact(self):
name = self.name_entry.get().strip()
phone = self.phone_entry.get().strip()
pubkey = self.contact_key_box.get("1.0", 'end-1c').strip()
if not name or not phone:
return
if not pubkey:
pubkey = None
self.controller.save_contact(name, phone, pubkey)
self.master.load_contacts()
self.destroy()
if __name__ == "__main__":
# Mock Controller for standalone GUI test
class MockController:
def get_contacts(self): return [("Alice", "+1234", "key"), ("Bob", "+5678", None)]
def get_contact_info(self, phone): return ("Alice", phone, "key") if phone == "+1234" else ("Bob", phone, None)
def get_messages(self, phone): return [(1, phone, "Hi", "2023-01-01", 0, "recv"), (2, phone, "Secret", "2023-01-01", 1, "sent")]
def get_my_public_key(self): return "MY_PEM_MOCK_DATA"
def send_message(self, *args): return True
def save_contact(self, *args): pass
app = AppGUI(MockController())
app.mainloop()

162
Crypto_Engine.py Normal file
View File

@ -0,0 +1,162 @@
import os
import base64
from cryptography.hazmat.primitives.asymmetric import x25519
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# Constants
PRIVATE_KEY_FILE = "private_key.pem"
PUBLIC_KEY_FILE = "public_key.pem"
class CryptoEngine:
def __init__(self):
self.private_key = None
self.public_key = None
self.load_or_generate_keys()
def load_or_generate_keys(self):
"""Loads keys from disk or generates a new pair if they don't exist."""
if os.path.exists(PRIVATE_KEY_FILE) and os.path.exists(PUBLIC_KEY_FILE):
with open(PRIVATE_KEY_FILE, "rb") as f:
self.private_key = serialization.load_pem_private_key(
f.read(), password=None
)
with open(PUBLIC_KEY_FILE, "rb") as f:
self.public_key = serialization.load_pem_public_key(f.read())
else:
self.generate_keypair()
def generate_keypair(self):
"""Generates a new X25519 keypair and saves it to disk."""
self.private_key = x25519.X25519PrivateKey.generate()
self.public_key = self.private_key.public_key()
# Save private key
with open(PRIVATE_KEY_FILE, "wb") as f:
f.write(self.private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
))
# Save public key
with open(PUBLIC_KEY_FILE, "wb") as f:
f.write(self.public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
))
def get_my_public_key_pem(self):
"""Returns my public key as string."""
return self.public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
def encrypt_message(self, message: str, peer_public_key_pem: str) -> str:
"""
Encrypts a message using X25519 exchange + HKDF + AESGCM.
Returns a base64 encoded string containing ephemeral public key, IV, and ciphertext.
"""
if not peer_public_key_pem:
raise ValueError("Peer public key is empty.")
peer_public_key = serialization.load_pem_public_key(peer_public_key_pem.encode('utf-8'))
# Generate an ephemeral keypair for this message to provide forward secrecy
ephemeral_private_key = x25519.X25519PrivateKey.generate()
ephemeral_public_key = ephemeral_private_key.public_key()
ephemeral_pub_bytes = ephemeral_public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw
)
# Perform key exchange
shared_key = ephemeral_private_key.exchange(peer_public_key)
# Derive a symmetric key using HKDF
derived_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b'sms-secure-encryption'
).derive(shared_key)
# Encrypt with AES-GCM
aesgcm = AESGCM(derived_key)
nonce = os.urandom(12)
ciphertext = aesgcm.encrypt(nonce, message.encode('utf-8'), None)
# Construct final payload: [Ephemeral Pub Key (32)] + [Nonce (12)] + [Ciphertext + Tag]
payload = ephemeral_pub_bytes + nonce + ciphertext
# We prepend a marker to easily identify secure messages
marker = "SEC:"
b64_payload = base64.b64encode(payload).decode('ascii')
return marker + b64_payload
def decrypt_message(self, secure_payload: str) -> str:
"""
Decrypts a secure payload.
Assumes it starts with 'SEC:'.
"""
if not secure_payload.startswith("SEC:"):
raise ValueError("Not a secure message format.")
b64_payload = secure_payload[4:]
try:
payload = base64.b64decode(b64_payload)
except Exception:
raise ValueError("Invalid Base64 payload.")
if len(payload) < 32 + 12 + 16: # Length of pub key + nonce + tag
raise ValueError("Payload too short.")
ephemeral_pub_bytes = payload[:32]
nonce = payload[32:44]
ciphertext = payload[44:]
ephemeral_public_key = x25519.X25519PublicKey.from_public_bytes(ephemeral_pub_bytes)
# Key exchange
shared_key = self.private_key.exchange(ephemeral_public_key)
# Derive symmetric key
derived_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b'sms-secure-encryption'
).derive(shared_key)
# Decrypt
aesgcm = AESGCM(derived_key)
try:
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
return plaintext.decode('utf-8')
except Exception as e:
raise ValueError("Decryption failed. Invalid key or message tampered.") from e
if __name__ == "__main__":
# Test
alice = CryptoEngine()
alice_pub = alice.get_my_public_key_pem()
bob = CryptoEngine()
bob_pub = bob.get_my_public_key_pem()
msg = "This is a highly secret message!"
# Alice sends to Bob
encrypted = alice.encrypt_message(msg, bob_pub)
print("Encrypted:", encrypted)
# Bob decrypts
decrypted = bob.decrypt_message(encrypted)
print("Decrypted:", decrypted)
assert msg == decrypted
print("Crypto Engine OK.")

134
DB_Handler.py Normal file
View File

@ -0,0 +1,134 @@
import sqlite3
from datetime import datetime
import os
DB_FILE = "sms_app.db"
class DBHandler:
def __init__(self, db_path=DB_FILE):
self.db_path = db_path
self._initialize_db()
def _get_connection(self):
return sqlite3.connect(self.db_path)
def _initialize_db(self):
with self._get_connection() as conn:
cursor = conn.cursor()
# Create contacts table
cursor.execute('''
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT UNIQUE NOT NULL,
public_key TEXT
)
''')
# Create messages table
cursor.execute('''
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL,
text TEXT NOT NULL,
date TEXT NOT NULL,
is_secure INTEGER NOT NULL,
status TEXT NOT NULL,
FOREIGN KEY(phone) REFERENCES contacts(phone)
)
''')
# Create settings table for our own keypair if needed (though usually we save keys to files)
cursor.execute('''
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)
''')
conn.commit()
# --- Contacts Methods ---
def add_contact(self, name, phone, public_key=None):
with self._get_connection() as conn:
cursor = conn.cursor()
try:
cursor.execute('INSERT INTO contacts (name, phone, public_key) VALUES (?, ?, ?)',
(name, phone, public_key))
conn.commit()
return True
except sqlite3.IntegrityError:
return False # Phone number already exists
def update_contact_key(self, phone, public_key):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('UPDATE contacts SET public_key = ? WHERE phone = ?', (public_key, phone))
conn.commit()
def get_contact(self, phone):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT name, phone, public_key FROM contacts WHERE phone = ?', (phone,))
return cursor.fetchone()
def get_all_contacts(self):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT name, phone, public_key FROM contacts')
return cursor.fetchall()
def set_contact_name_if_not_exists(self, phone, name):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT phone FROM contacts WHERE phone = ?', (phone,))
if not cursor.fetchone():
cursor.execute('INSERT INTO contacts (name, phone, public_key) VALUES (?, ?, ?)', (name, phone, None))
conn.commit()
# --- Messages Methods ---
def add_message(self, phone, text, is_secure, status):
date_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO messages (phone, text, date, is_secure, status)
VALUES (?, ?, ?, ?, ?)
''', (phone, text, date_str, int(is_secure), status))
conn.commit()
return cursor.lastrowid
def get_messages_for_contact(self, phone):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, phone, text, date, is_secure, status
FROM messages
WHERE phone = ?
ORDER BY date ASC
''', (phone,))
return cursor.fetchall()
def delete_message(self, msg_id):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM messages WHERE id = ?', (msg_id,))
conn.commit()
# --- Settings Methods ---
def get_setting(self, key):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT value FROM settings WHERE key = ?', (key,))
row = cursor.fetchone()
return row[0] if row else None
def set_setting(self, key, value):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', (key, value))
conn.commit()
if __name__ == "__main__":
db = DBHandler("test_sms.db")
db.add_contact("Alice", "+1234567890", "PUBLIC_KEY_CONTENT")
db.add_message("+1234567890", "Hello Alice!", is_secure=False, status="sent")
print("Database OK.")
os.remove("test_sms.db")

218
GSM_Manager.py Normal file
View File

@ -0,0 +1,218 @@
import serial
import time
import threading
import re
class GSMManager:
def __init__(self, port='COM1', baudrate=115200, message_callback=None):
"""
message_callback: function(sender_number, message_text)
called when a new SMS is fully read.
"""
self.port = port
self.baudrate = baudrate
self.serial_conn = None
self.is_running = False
self.read_thread = None
self.message_callback = message_callback
# Lock for thread-safe AT command execution
self.lock = threading.Lock()
def connect(self):
try:
self.serial_conn = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=1 # 1 second timeout for readline
)
self.is_running = True
# Setup Modem
self.send_at_cmd('AT')
self.send_at_cmd('AT+CMGF=1') # Text Mode
self.send_at_cmd('AT+CNMI=2,1,0,0,0') # Route URC to TE for +CMTI
# Start Read Thread
self.read_thread = threading.Thread(target=self._read_loop, daemon=True)
self.read_thread.start()
return True
except Exception as e:
print(f"Failed to connect GSM: {e}")
return False
def disconnect(self):
self.is_running = False
if self.serial_conn and self.serial_conn.is_open:
self.serial_conn.close()
if self.read_thread:
self.read_thread.join(timeout=2)
def send_at_cmd(self, cmd, expected_resp='OK', timeout=2):
"""Send an AT command safely and wait for a response."""
with self.lock:
if not self.serial_conn or not self.serial_conn.is_open:
return False, "Not connected"
# Flush input buffer to clear old data
self.serial_conn.reset_input_buffer()
full_cmd = cmd + '\r\n'
self.serial_conn.write(full_cmd.encode('ascii'))
start_time = time.time()
response_lines = []
while time.time() - start_time < timeout:
if self.serial_conn.in_waiting:
line = self.serial_conn.readline().decode('ascii', errors='ignore').strip()
if line:
response_lines.append(line)
if expected_resp in line or 'ERROR' in line:
break
else:
time.sleep(0.05)
return expected_resp in "\n".join(response_lines), response_lines
def send_sms(self, phone_number, message):
"""
Sends an SMS. For long messages, splits them up.
(Note: Quectel M66 supports up to 160 chars in 7-bit text mode natively.
For simplicity, we chunk it to 150 chars max per SMS if it exceeds).
"""
chunk_size = 140 # Safe limit for standard ASCII/Base64
chunks = [message[i:i+chunk_size] for i in range(0, len(message), chunk_size)]
for idx, chunk in enumerate(chunks):
# If multiple chunks, we can prepend (1/2) kind of indicator for normal texts
# But for base64 secure chunks, they might need stitching.
# For simplicity, we just send them sequentially.
# In a production resilient app, multipart PDU mode is preferred.
success = self._send_single_sms(phone_number, chunk)
if not success:
return False
if len(chunks) > 1:
time.sleep(2) # Add some delay between multi-part sends
return True
def _send_single_sms(self, phone_number, text):
with self.lock:
try:
self.serial_conn.reset_input_buffer()
# Start SMS prompt
self.serial_conn.write(f'AT+CMGS="{phone_number}"\r\n'.encode('ascii'))
# Wait for '>'
start_time = time.time()
prompt_ready = False
while time.time() - start_time < 2:
if self.serial_conn.in_waiting:
char = self.serial_conn.read().decode('ascii', errors='ignore')
if char == '>':
prompt_ready = True
break
time.sleep(0.05)
if not prompt_ready:
print("SMS prompt '>' not received.")
# Send ESC just in case
self.serial_conn.write(chr(27).encode('ascii'))
return False
# Write text and send Ctrl+Z (ASCII 26)
self.serial_conn.write(text.encode('ascii') + chr(26).encode('ascii'))
# Wait for +CMGS and OK
start_time = time.time()
success = False
response_lines = []
while time.time() - start_time < 10: # SMS sending can take time
if self.serial_conn.in_waiting:
line = self.serial_conn.readline().decode('ascii', errors='ignore').strip()
if line:
response_lines.append(line)
if 'OK' in line:
success = True
break
elif 'ERROR' in line:
break
else:
time.sleep(0.1)
return success
except Exception as e:
print(f"Error sending SMS: {e}")
return False
def _read_loop(self):
"""Background thread continuously reading URCs like +CMTI"""
while self.is_running:
try:
if self.serial_conn and self.serial_conn.in_waiting:
# We only read if we can acquire the lock without waiting,
# otherwise it means AT command is in progress and it handles reading.
# Wait, AT command reading in `send_at_cmd` reads synchronously. URCs might mix.
# Standard practice for URCs: the AT command sender ignores unexpected URCs,
# OR we have a dedicated thread that reads everything and routes URCs vs command responses.
# For simplicity, we try to grab the lock. If locked, AT comm is happening.
if self.lock.acquire(blocking=False):
try:
line = self.serial_conn.readline().decode('ascii', errors='ignore').strip()
if line.startswith('+CMTI:'):
# Incoming message: +CMTI: "SM",1
match = re.search(r'\+CMTI:\s*".*?",(\d+)', line)
if match:
msg_index = int(match.group(1))
# Process it in a new thread or queue to avoid blocking this loop
threading.Thread(target=self.process_incoming_sms, args=(msg_index,), daemon=True).start()
finally:
self.lock.release()
except Exception as e:
pass
time.sleep(0.1)
def process_incoming_sms(self, index):
"""Reads the SMS payload from memory and deletes it."""
# read_sms uses AT command, so it uses the lock
# wait a bit ensuring any ongoing AT comm finishes
time.sleep(1)
success, response = self.send_at_cmd(f'AT+CMGR={index}', expected_resp='OK', timeout=3)
if success:
sender = "Unknown"
text_lines = []
is_text_block = False
for line in response:
if line.startswith('+CMGR:'):
# +CMGR: "REC UNREAD","+989123456789",,"23/05/26,10:30:00+14"
parts = line.split(',')
if len(parts) >= 2:
sender = parts[1].strip('"')
is_text_block = True
elif is_text_block and line not in ['OK', 'ERROR'] and not line.startswith('+CMGR:'):
text_lines.append(line)
full_text = "\n".join(text_lines)
if self.message_callback:
self.message_callback(sender, full_text)
# Delete message to free space
self.send_at_cmd(f'AT+CMGD={index}')
if __name__ == "__main__":
def on_sms(sender, text):
print(f"\n[NEW SMS] From: {sender}\nText:{text}\n")
gsm = GSMManager(port='COM3')
print("Testing locally...")
# This will obviously fail without actual M66 module on COM3, but acts as boilerplate check.
# gsm.connect()
# gsm.disconnect()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

7
_bad_strings.txt Normal file
View File

@ -0,0 +1,7 @@
57: ?
276: ?????
278: ?????
280: ????
281: ???
283: ???
284: ?????

6
_question_strings.txt Normal file
View File

@ -0,0 +1,6 @@
276|?????
278|?????
280|????
281|???
283|???
284|?????

643
_string_tokens.txt Normal file
View File

@ -0,0 +1,643 @@
15|light
16|blue
19|#175B4B
20|#0E4236
21|#DFF1E8
22|#E8A04D
23|#C97E2D
24|#F5EFE7
25|#FFFDFC
26|#FBF7F2
27|#FFFCF8
28|#16312A
29|#6B7A77
30|#B6465F
31|#9A6C3C
32|#E5DCCE
33|#E7DED3
34|#FFF8F0
35|#F2E7D8
37|#1B5A4A
38|#245E4E
39|DejaVu Sans
40|DejaVu Sans
41|[\\u0600-\\u06FF]
43|fa
44|\u0636
44|\u0635
44|\u062b
44|\u0642
44|\u0641
44|\u063a
44|\u0639
44|\u0647
44|\u062e
44|\u062d
45|\u0634
45|\u0633
45|\u06cc
45|\u0628
45|\u0644
45|\u0627
45|\u062a
45|\u0646
45|\u0645
45|\u06a9
46|123
46|\u0638
46|\u0637
46|\u0632
46|\u0631
46|\u0630
46|\u062f
46|\u067e
46|\u0648
46|\u067e\u0627\u06a9
47|English
47|\u060c
47|\u0641\u0627\u0635\u0644\u0647
47|\u062a\u0627\u06cc\u06cc\u062f
49|en
50|q
50|w
50|e
50|r
50|t
50|y
50|u
50|i
50|o
50|p
51|a
51|s
51|d
51|f
51|g
51|h
51|j
51|k
51|l
52|123
52|z
52|x
52|c
52|v
52|b
52|n
52|m
52|Back
53|\u0641\u0627\u0631\u0633\u06cc
53|.
53|Space
53|Enter
55|numeric
56|1
56|2
56|3
56|4
56|5
56|6
56|7
56|8
56|9
56|0
57|+
57|-
57|/
57|@
57|_
57|.
57|:
57|(
57|)
57|?
58|\u0641\u0627\u0631\u0633\u06cc
58|English
58|Back
59|\u0628\u0633\u062a\u0646
59|\u0641\u0627\u0635\u0644\u0647
59|\u062a\u0627\u06cc\u06cc\u062f
63|\u0641\u0627\u0631\u0633\u06cc
63|fa
64|English
64|en
65|123
65|numeric
66|\u067e\u0627\u06a9
66|backspace
67|\u0628\u0633\u062a\u0646
67|close
83|text
84|text
84|text
85|placeholder_text
86|placeholder_text
86|placeholder_text
87|label_text
88|label_text
88|label_text
102|corner_radius
111|justify
111|right
112|fg_color
113|border_color
114|corner_radius
140|fa
144|SECURE_SMS_WINDOWED
144|0
144|1
145|\u067e\u06cc\u0627\u0645\u200c\u0631\u0633\u0627\u0646 \u0627\u0645\u0646 \u0635\u0628\u0627
146|800x480
150|*Cursor
150|none
152|<Button-1>
152|+
161|-topmost
162|-fullscreen
164|800x480+0+0
169|none
176|_entry
176|_textbox
184|master
191|master
199|master
211|fa
213|title
214|layout
215|multiline
216|submit
221|<FocusIn>
221|+
222|<Button-1>
222|+
234|layout
271|title
272|\u0648\u0631\u0648\u062f\u06cc
276|?????
277|Space
278|?????
279|Enter
280|????
281|???
282|Back
283|???
284|?????
285|English
286|123
290|\u062a\u0627\u06cc\u06cc\u062f
290|Enter
291|white
292|ABC
292|\u0641\u0627
292|\u06f1\u06f2\u06f3
292|123
292|English
292|\u0641\u0627\u0631\u0633\u06cc
293|#3A2514
294|\u062d\u0630\u0641
294|\u0628\u0633\u062a\u0646
294|Back
294|\u067e\u0627\u06a9
295|#E4D4C2
296|#F2E6D8
299|fa
299|en
299|numeric
301|white
301|#3A2514
302|backspace
303|#E4D4C2
304|#F4EFE9
304|#E8D8C7
307|fa
307|en
307|numeric
309|backspace
311|close
328|bold
330|ew
336|fa
337|transparent
338|ew
349|bold
351|ew
354|ABC
354|English
355|en
357|\u0641\u0627
357|\u0641\u0627\u0631\u0633\u06cc
358|fa
360|\u06f1\u06f2\u06f3
360|123
361|numeric
363|\u0641\u0627\u0635\u0644\u0647
363|Space
364|
366|\u062d\u0630\u0641
366|Back
366|\u067e\u0627\u06a9
369|\u0628\u0633\u062a\u0646
372|\u062a\u0627\u06cc\u06cc\u062f
372|Enter
384|sel
387|insert
388|insert
392|sel.first
392|sel.last
395|insert
407|sel
410|insert
410|>
410|1.0
411|insert-1c
412|insert
416|sel.first
416|sel.last
421|insert
433|submit
437|multiline
438|\n
448|nsew
453|ew
463|ew
466|transparent
467|ew
472|\u06a9\u06cc\u0628\u0648\u0631\u062f \u0644\u0645\u0633\u06cc
474|bold
475|e
478|\u0628\u0633\u062a\u0646
482|#E4D4C2
484|bold
486|e
490|
494|e
496|transparent
497|ew
500|transparent
501|ew
522|center
523|\u0631\u0627\u0647\u200c\u0627\u0646\u062f\u0627\u0632\u06cc \u0627\u0645\u0646 \u0628\u0631\u0646\u0627\u0645\u0647
523|\u0648\u0631\u0648\u062f \u0628\u0647 \u0628\u0631\u0646\u0627\u0645\u0647
525|\u06cc\u06a9 \u0631\u0645\u0632 \u0627\u0635\u0644\u06cc \u062a\u0639\u06cc\u06cc\u0646 \u06a9\u0646 \u062a\u0627 \u06a9\u0644\u06cc\u062f\u0647\u0627 \u0648 \u062f\u0627\u062f\u0647\u200c\u0647\u0627\u06cc \u062d\u0633\u0627\u0633 \u062f\u0627\u062e\u0644 \u062f\u06cc\u062a\u0627\u0628\u06cc\u0633 \u0628\u0647 \u0635\u0648\u0631\u062a \u0631\u0645\u0632\u200c\u0634\u062f\u0647 \u0646\u06af\u0647\u200c\u062f\u0627\u0631\u06cc \u0634\u0648\u0646\u062f.
527|\u0628\u0631\u0627\u06cc \u0648\u0631\u0648\u062f\u060c \u0631\u0645\u0632 \u0627\u0635\u0644\u06cc \u0628\u0631\u0646\u0627\u0645\u0647 \u0631\u0627 \u0648\u0627\u0631\u062f \u06a9\u0646.
532|bold
539|right
545|\u0631\u0645\u0632 \u0627\u0635\u0644\u06cc
546|*
550|x
555|\u062a\u06a9\u0631\u0627\u0631 \u0631\u0645\u0632
556|*
560|x
563|
568|\u0634\u0631\u0648\u0639 \u0628\u0631\u0646\u0627\u0645\u0647
568|\u0648\u0631\u0648\u062f
576|bold
577|x
578|<Return>
579|\u0631\u0645\u0632 \u0627\u0635\u0644\u06cc
579|en
581|<Return>
582|\u062a\u06a9\u0631\u0627\u0631 \u0631\u0645\u0632
582|en
587|\u0631\u0645\u0632 \u0628\u0627\u06cc\u062f \u062d\u062f\u0627\u0642\u0644 \u06f6 \u06a9\u0627\u0631\u0627\u06a9\u062a\u0631 \u0628\u0627\u0634\u062f.
592|\u062a\u06a9\u0631\u0627\u0631 \u0631\u0645\u0632 \u0628\u0627 \u0631\u0645\u0632 \u0627\u0635\u0644\u06cc \u06cc\u06a9\u0633\u0627\u0646 \u0646\u06cc\u0633\u062a.
600|\u0631\u0645\u0632 \u0648\u0627\u0631\u062f \u0634\u062f\u0647 \u062f\u0631\u0633\u062a \u0646\u06cc\u0633\u062a.
608|nsew
613|\xd8\xb5\xd8\xa8\xd8\xa7
614|white
615|bold
616|e
619|\u067e\u06cc\u0627\u0645\u200c\u0631\u0633\u0627\u0646 \u0627\u0645\u0646 \u0648 \u0633\u0627\u062f\u0647 \u0628\u0631\u0627\u06cc \u06a9\u0627\u0631\u0628\u0631 \u063a\u06cc\u0631 \u0641\u0646\u06cc
620|#D5E8E1
622|e
626|
628|#2E7D62
629|white
630|bold
634|e
636|transparent
637|ew
641|\u0645\u062e\u0627\u0637\u0628 \u062c\u062f\u06cc\u062f
644|#3A2514
647|bold
648|ew
651|\u062a\u0646\u0638\u06cc\u0645\u0627\u062a
653|#F4EFE9
654|#15302B
655|#ECE1D5
657|bold
658|ew
665|#3B7B66
667|ew
671|\u0645\u062e\u0627\u0637\u0628 \u062c\u062f\u06cc\u062f
672|white
673|bold
674|e
677|\u0646\u0627\u0645 \u0645\u062e\u0627\u0637\u0628
681|ew
684|\u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06cc\u0644
688|ew
691|
692|#FDE68A
695|e
696|transparent
697|ew
701|\u0630\u062e\u06cc\u0631\u0647
703|#3A2514
706|bold
708|ew
711|\xd8\xa8\xd8\xb3\xd8\xaa\xd9\u2020
712|#F4EFE9
714|#ECE1D5
716|bold
718|ew
719|<Return>
720|\u0646\u0627\u0645 \u0645\u062e\u0627\u0637\u0628
720|fa
723|\u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06cc\u0644
724|numeric
731|\u0645\u062e\u0627\u0637\u0628\u200c\u0647\u0627
732|bold
733|transparent
735|nsew
738|nsew
743|ew
748|\u06cc\u06a9 \u0645\u062e\u0627\u0637\u0628 \u0631\u0627 \u0627\u0646\u062a\u062e\u0627\u0628 \u06a9\u0646
750|bold
752|e
755|\u062f\u0631 \u0627\u06cc\u0646\u062c\u0627 \u0641\u0642\u0637 \u062f\u0648 \u062d\u0627\u0644\u062a \u062f\u0627\u0631\u06cc: \u0639\u0627\u062f\u06cc \u06cc\u0627 \u0627\u0645\u0646
759|e
762|\xd8\xb9\xd8\xa7\xd8\xaf\xdb\u0152
768|bold
770|e
772|transparent
773|nsew
785|word
787|nsew
788|out
788|right
789|in
789|right
790|system
790|#8A5C2E
790|right
791|disabled
794|nsew
798|\u067e\u0631\u0648\u0641\u0627\u06cc\u0644 \u0645\u062e\u0627\u0637\u0628
800|bold
801|e
804|\u0646\u0627\u0645 \u0645\u062e\u0627\u0637\u0628
806|bold
808|e
811|\u0634\u0645\u0627\u0631\u0647
815|e
818|\u0628\u0631\u0627\u06cc \u0627\u06cc\u0646 \u0645\u062e\u0627\u0637\u0628 \u0647\u0646\u0648\u0632 \u062d\u0627\u0644\u062a \u0627\u0645\u0646 \u0641\u0639\u0627\u0644 \u0646\u0634\u062f\u0647 \u0627\u0633\u062a.
820|right
824|e
827|\u0641\u0639\u0627\u0644\u200c\u0633\u0627\u0632\u06cc \u0627\u0631\u062a\u0628\u0627\u0637 \u0627\u0645\u0646
830|bold
834|ew
837|\u0628\u0627\u0632\u06af\u0634\u062a \u0628\u0647 \u062d\u0627\u0644\u062a \u0639\u0627\u062f\u06cc
838|#F4EFE9
840|#ECE1D5
841|bold
845|ew
848|ew
856|word
858|ew
859|transparent
860|ns
863|
870|\xd8\xa7\xd8\xb1\xd8\xb3\xd8\xa7\xd9\u201e
872|#3A2514
874|bold
879|<Control-Return>
880|\u0645\u062a\u0646 \u067e\u06cc\u0627\u0645
880|fa
889|nsew
906|connected
907|port
907|#2E7D62
909|port
909|#9A6C3C
918|\u0647\u0646\u0648\u0632 \u0645\u062e\u0627\u0637\u0628\u06cc \u0627\u0636\u0627\u0641\u0647 \u0646\u0634\u062f\u0647 \u0627\u0633\u062a.
919|#D5E8E1
921|e
927|\u0622\u0645\u0627\u062f\u0647 \u06af\u0641\u062a\u06af\u0648
928|e
932|#F7EFE5
932|#2A6956
933|#F0E3D5
933|#32745F
934|white
937|ew
946|\u06cc\u06a9 \u0645\u062e\u0627\u0637\u0628 \u0631\u0627 \u0627\u0646\u062a\u062e\u0627\u0628 \u06a9\u0646
947|\u062f\u0631 \u0627\u06cc\u0646\u062c\u0627 \u0641\u0642\u0637 \u062f\u0648 \u062d\u0627\u0644\u062a \u062f\u0627\u0631\u06cc: \u0639\u0627\u062f\u06cc \u06cc\u0627 \u0627\u0645\u0646
948|\xd8\xb9\xd8\xa7\xd8\xaf\xdb\u0152
949|\u0646\u0627\u0645 \u0645\u062e\u0627\u0637\u0628
950|\u0634\u0645\u0627\u0631\u0647
951|\u0628\u0631\u0627\u06cc \u0634\u0631\u0648\u0639\u060c \u06cc\u06a9 \u0645\u062e\u0627\u0637\u0628 \u0627\u0632 \u0633\u062a\u0648\u0646 \u0633\u0645\u062a \u0631\u0627\u0633\u062a \u0627\u0646\u062a\u062e\u0627\u0628 \u06a9\u0646.
960|pending
961|\xd8\xaf\xd8\xb1 \xd8\xa7\xd9\u2020\xd8\xaa\xd8\xb8\xd8\xa7\xd8\xb1
961|#FCEBD7
961|#9A6C3C
962|\u062f\u0631\u062e\u0648\u0627\u0633\u062a \u0627\u0631\u062a\u0628\u0627\u0637 \u0627\u0645\u0646 \u0627\u0631\u0633\u0627\u0644 \u0634\u062f\u0647 \u0648 \u0628\u0631\u0646\u0627\u0645\u0647 \u0645\u0646\u062a\u0638\u0631 \u067e\u0627\u0633\u062e \u0637\u0631\u0641 \u0645\u0642\u0627\u0628\u0644 \u0627\u0633\u062a.
963|secure
964|\u0627\u0645\u0646
964|#D9F5E8
964|#0F8A5F
965|\u0627\u0631\u062a\u0628\u0627\u0637 \u0627\u0645\u0646 \u0641\u0639\u0627\u0644 \u0627\u0633\u062a. \u0647\u0631 \u0632\u0645\u0627\u0646 \u0628\u062e\u0648\u0627\u0647\u06cc \u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u06cc \u0628\u0647 \u062d\u0627\u0644\u062a \u0639\u0627\u062f\u06cc \u0628\u0631\u06af\u0631\u062f\u06cc.
967|\xd8\xb9\xd8\xa7\xd8\xaf\xdb\u0152
969|\u06a9\u0644\u06cc\u062f \u0627\u06cc\u0646 \u0645\u062e\u0627\u0637\u0628 \u0622\u0645\u0627\u062f\u0647 \u0627\u0633\u062a. \u0627\u06af\u0631 \u0628\u062e\u0648\u0627\u0647\u06cc \u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u06cc \u062f\u0648\u0628\u0627\u0631\u0647 \u0627\u0631\u062a\u0628\u0627\u0637 \u0627\u0645\u0646 \u0631\u0627 \u0641\u0639\u0627\u0644 \u06a9\u0646\u06cc.
971|\u0628\u0631\u0627\u06cc \u0627\u0645\u0646 \u0634\u062f\u0646 \u06af\u0641\u062a\u06af\u0648\u060c \u0641\u0642\u0637 \u0631\u0648\u06cc \u062f\u06a9\u0645\u0647 \u0641\u0639\u0627\u0644\u200c\u0633\u0627\u0632\u06cc \u0627\u0631\u062a\u0628\u0627\u0637 \u0627\u0645\u0646 \u0628\u0632\u0646.
972|normal
973|normal
973|secure
973|pending
973|disabled
977|normal
978|1.0
978|end
980|end
980|\n\u0647\u0646\u0648\u0632 \u067e\u06cc\u0627\u0645\u06cc \u062b\u0628\u062a \u0646\u0634\u062f\u0647 \u0627\u0633\u062a.\n
980|system
982|system
983|end
983|system
985|\xd8\xb4\xd9\u2026\xd8\xa7
985|out
985|\xd9\u2026\xd8\xae\xd8\xa7\xd8\xb7\xd8\xa8
986| | \u0627\u0645\u0646
986|secure
986|
987|out
987|out
987|in
989|end
993|disabled
994|end
998|\u0627\u0648\u0644 \u06cc\u06a9 \u0645\u062e\u0627\u0637\u0628 \u0631\u0627 \u0627\u0646\u062a\u062e\u0627\u0628 \u06a9\u0646.
1000|1.0
1000|end-1c
1002|\u0645\u062a\u0646 \u067e\u06cc\u0627\u0645 \u062e\u0627\u0644\u06cc \u0627\u0633\u062a.
1006|1.0
1006|end
1007|\xd8\xa7\xd8\xb1\xd8\xb3\xd8\xa7\xd9\u201e \xd8\xb4\xd8\xaf.
1007|sent
1007|\u062f\u0631 \u062d\u0627\u0644\u062a \u0622\u0641\u0644\u0627\u06cc\u0646\u060c \u067e\u06cc\u0627\u0645 \u0628\u0647 \u0635\u0648\u0631\u062a \u0634\u0628\u06cc\u0647\u200c\u0633\u0627\u0632\u06cc \u062b\u0628\u062a \u0634\u062f.
1019|\u062f\u0631\u062e\u0648\u0627\u0633\u062a \u0627\u0631\u062a\u0628\u0627\u0637 \u0627\u0645\u0646 \u0627\u0631\u0633\u0627\u0644 \u0634\u062f.
1019|sent
1019|\u062f\u0631 \u062d\u0627\u0644\u062a \u0622\u0641\u0644\u0627\u06cc\u0646\u060c \u062f\u0631\u062e\u0648\u0627\u0633\u062a \u0627\u0645\u0646 \u0628\u0647 \u0635\u0648\u0631\u062a \u0645\u062d\u0644\u06cc \u062b\u0628\u062a \u0634\u062f.
1023|\u0627\u0631\u0633\u0627\u0644 \u062f\u0631\u062e\u0648\u0627\u0633\u062a \u0627\u0645\u0646 \u0646\u0627\u0645\u0648\u0641\u0642 \u0628\u0648\u062f.
1032|\u06af\u0641\u062a\u06af\u0648 \u0628\u0647 \u062d\u0627\u0644\u062a \u0639\u0627\u062f\u06cc \u0628\u0631\u06af\u0634\u062a.
1032|sent
1032|\u062f\u0631 \u062d\u0627\u0644\u062a \u0622\u0641\u0644\u0627\u06cc\u0646\u060c \u0628\u0627\u0632\u06af\u0634\u062a \u0628\u0647 \u0639\u0627\u062f\u06cc \u0645\u062d\u0644\u06cc \u062b\u0628\u062a \u0634\u062f.
1036|\u0628\u0627\u0632\u06af\u0634\u062a \u0628\u0647 \u062d\u0627\u0644\u062a \u0639\u0627\u062f\u06cc \u0627\u0646\u062c\u0627\u0645 \u0646\u0634\u062f.
1043|
1044|end
1045|end
1052|
1058|\u0646\u0627\u0645 \u0648 \u0634\u0645\u0627\u0631\u0647 \u0647\u0631 \u062f\u0648 \u0644\u0627\u0632\u0645 \u0647\u0633\u062a\u0646\u062f.
1062|\u0645\u062e\u0627\u0637\u0628 \u0630\u062e\u06cc\u0631\u0647 \u0634\u062f.
1063|end
1064|end
1072|\u062a\u0646\u0638\u06cc\u0645\u0627\u062a
1073|\u0647\u0645\u0647 \u0628\u062e\u0634\u200c\u0647\u0627 \u062f\u0627\u062e\u0644 \u0647\u0645\u06cc\u0646 \u067e\u0646\u062c\u0631\u0647 \u0628\u0627\u0632 \u0645\u06cc\u200c\u0634\u0648\u0646\u062f \u062a\u0627 \u0628\u0631\u0627\u06cc \u0646\u0645\u0627\u06cc\u0634\u06af\u0631 \u06f7 \u0627\u06cc\u0646\u0686\u06cc \u0633\u0627\u062f\u0647 \u0648 \u0642\u0627\u0628\u0644 \u0644\u0645\u0633 \u0628\u0645\u0627\u0646\u0646\u062f.
1075|x
1078|both
1083|\u0648\u0636\u0639\u06cc\u062a \u0641\u0639\u0644\u06cc \u062f\u0633\u062a\u06af\u0627\u0647
1085|bold
1086|e
1089|\u0645\u062a\u0635\u0644
1089|connected
1089|\u0622\u0641\u0644\u0627\u06cc\u0646
1089|port
1089|baudrate
1092|right
1094|e
1097|\u0648\u0631\u0648\u062f \u0628\u0647 \u067e\u0646\u0644 \u0627\u062f\u0645\u06cc\u0646
1100|bold
1103|x
1106|\u0628\u0627\u0632\u06af\u0634\u062a \u0628\u0647 \u06af\u0641\u062a\u06af\u0648
1107|#F1E7DB
1109|#E8D8C7
1110|bold
1113|x
1131|transparent
1135|\xd8\xa8\xd8\xb3\xd8\xaa\xd9\u2020
1138|#F1E7DB
1140|#E8D8C7
1141|bold
1143|e
1148|bold
1149|e
1155|right
1157|e
1163|\u0648\u0631\u0648\u062f \u0627\u062f\u0645\u06cc\u0646
1164|\u062c\u0632\u0626\u06cc\u0627\u062a \u0641\u0646\u06cc\u060c \u0644\u0627\u06af\u200c\u0647\u0627 \u0648 \u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u0627\u0645\u0646\u06cc\u062a\u06cc \u0641\u0642\u0637 \u0628\u0639\u062f \u0627\u0632 \u0648\u0631\u0648\u062f \u0627\u062f\u0645\u06cc\u0646 \u0646\u0645\u0627\u06cc\u0634 \u062f\u0627\u062f\u0647 \u0645\u06cc\u200c\u0634\u0648\u0646\u062f.
1166|x
1169|both
1172|\u0631\u0645\u0632 \u0627\u0635\u0644\u06cc \u0631\u0627 \u0648\u0627\u0631\u062f \u06a9\u0646
1174|bold
1175|e
1178|\u0631\u0645\u0632 \u0627\u0635\u0644\u06cc
1179|*
1183|x
1184|
1185|e
1189|\u0631\u0645\u0632 \u0627\u062f\u0645\u06cc\u0646 \u0635\u062d\u06cc\u062d \u0646\u06cc\u0633\u062a.
1195|\u0648\u0631\u0648\u062f \u0628\u0647 \u067e\u0646\u0644 \u0627\u062f\u0645\u06cc\u0646
1198|bold
1201|x
1204|\u0628\u0627\u0632\u06af\u0634\u062a \u0628\u0647 \u062a\u0646\u0638\u06cc\u0645\u0627\u062a
1205|#F1E7DB
1207|#E8D8C7
1208|bold
1211|x
1212|\u0631\u0645\u0632 \u0627\u0635\u0644\u06cc \u0627\u062f\u0645\u06cc\u0646
1212|en
1217|\u067e\u0646\u0644 \u0627\u062f\u0645\u06cc\u0646
1218|\u0627\u06cc\u0646 \u0628\u062e\u0634 \u0628\u0631\u0627\u06cc \u0646\u0645\u0627\u06cc\u0634\u06af\u0631 \u06a9\u0648\u0686\u06a9 \u062e\u0644\u0627\u0635\u0647 \u0634\u062f\u0647 \u062a\u0627 \u0627\u0637\u0644\u0627\u0639\u0627\u062a \u0627\u0635\u0644\u06cc\u060c \u0644\u0627\u06af\u200c\u0647\u0627 \u0648 \u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u0645\u0648\u062f\u0645/\u0631\u0645\u0632 \u062f\u0627\u062e\u0644 \u0647\u0645\u0627\u0646 \u067e\u0646\u062c\u0631\u0647 \u062f\u0631 \u062f\u0633\u062a\u0631\u0633 \u0628\u0627\u0634\u0646\u062f.
1220|x
1223|stats
1224|system_info
1230|
1232|both
1236|\u06a9\u0644 \u0645\u062e\u0627\u0637\u0628\u200c\u0647\u0627
1236|contacts
1237|\u0645\u062e\u0627\u0637\u0628 \u0627\u0645\u0646
1237|secure_contacts
1238|\xd8\xaf\xd8\xb1 \xd8\xa7\xd9\u2020\xd8\xaa\xd8\xb8\xd8\xa7\xd8\xb1
1238|pending_contacts
1239|\u067e\u06cc\u0627\u0645 \u0627\u0645\u0646
1239|secure_messages
1243|ew
1249|e
1254|bold
1255|e
1258|ew
1261|\u0627\u0637\u0644\u0627\u0639\u0627\u062a \u0633\u06cc\u0633\u062a\u0645\u06cc
1263|bold
1264|e
1267|platform
1267|modem_port
1267|baudrate
1267|fingerprint
1269|right
1272|e
1275|ew
1278|\u0622\u062e\u0631\u06cc\u0646 \u0644\u0627\u06af\u200c\u0647\u0627
1280|bold
1281|e
1282|word
1283|x
1284|events
1285|events
1286|end
1288|end
1288|\u0647\u0646\u0648\u0632 \u0644\u0627\u06af\u06cc \u062b\u0628\u062a \u0646\u0634\u062f\u0647 \u0627\u0633\u062a.
1289|disabled
1292|ew
1295|\u0628\u0633\u062a\u0647\u200c\u0647\u0627\u06cc \u0646\u0627\u0642\u0635
1297|bold
1298|e
1299|word
1300|x
1301|pending_packets
1302|pending_packets
1303|end
1305|end
1305|\u067e\u06cc\u0627\u0645 \u0646\u0627\u0642\u0635\u06cc \u062f\u0631 \u0635\u0641 \u0648\u062c\u0648\u062f \u0646\u062f\u0627\u0631\u062f.
1306|disabled
1309|ew
1312|\u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u0645\u0648\u062f\u0645 \u0648 \u0631\u0645\u0632
1314|bold
1315|e
1316|\u067e\u0648\u0631\u062a \u0645\u0648\u062f\u0645 \u0645\u062b\u0644 COM3
1317|x
1318|modem_port
1319|Baudrate
1320|x
1321|baudrate
1322|\u0631\u0645\u0632 \u0641\u0639\u0644\u06cc
1322|*
1323|x
1324|\u0631\u0645\u0632 \u062c\u062f\u06cc\u062f
1324|*
1325|x
1326|
1327|e
1328|\u067e\u0648\u0631\u062a \u0645\u0648\u062f\u0645
1328|en
1329|Baudrate
1329|numeric
1330|\u0631\u0645\u0632 \u0641\u0639\u0644\u06cc
1330|en
1331|\u0631\u0645\u0632 \u062c\u062f\u06cc\u062f
1331|en
1339|\u062a\u063a\u06cc\u06cc\u0631\u0627\u062a \u0630\u062e\u06cc\u0631\u0647 \u0634\u062f \u0648 \u0645\u0648\u062f\u0645 \u0645\u062a\u0635\u0644 \u0627\u0633\u062a.
1341|\u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u0630\u062e\u06cc\u0631\u0647 \u0634\u062f\u060c \u0627\u0645\u0627 \u0645\u0648\u062f\u0645 \u0647\u0646\u0648\u0632 \u0622\u0641\u0644\u0627\u06cc\u0646 \u0627\u0633\u062a.
1341|#9A6C3C
1347|\u0630\u062e\u06cc\u0631\u0647 \u062a\u063a\u06cc\u06cc\u0631\u0627\u062a
1350|bold
1353|x

44
a.ps1 Normal file
View File

@ -0,0 +1,44 @@
# -----------------------------
# تنظیمات
# -----------------------------
$LocalPath = "C:\Users\Pars\Desktop\saba-python" # مسیر پروژه روی ویندوز
$PiUser = "pars" # کاربر روی Raspberry Pi
$PiHost = "10.65.40.150" # آی‌پی Raspberry Pi
$RemotePath = "/home/pars/Desktop/" # مسیر پروژه روی Pi
$MainPy = "saba-python/main.py" # فایل اصلی پایتون
$KeyPath = "$env:USERPROFILE\.ssh\id_ed25519"
# -----------------------------
# ساخت کلید SSH اگر موجود نباشد
# -----------------------------
if (-not (Test-Path $KeyPath)) {
Write-Host "Generating SSH key..."
ssh-keygen -t ed25519 -f $KeyPath -N "" -C "$PiUser@windows"
} else {
Write-Host "SSH key already exists."
}
# -----------------------------
# اضافه کردن کلید به Pi (رمز یک بار لازم است)
# -----------------------------
Write-Host "Copying public key to Raspberry Pi..."
$pubKey = Get-Content "$KeyPath.pub"
# دستور برای اضافه کردن کلید به authorized_keys روی Pi
$command = "mkdir -p ~/.ssh; chmod 700 ~/.ssh; echo '$pubKey' >> ~/.ssh/authorized_keys; chmod 600 ~/.ssh/authorized_keys"
ssh "$PiUser@$PiHost" $command
# -----------------------------
# انتقال پروژه با scp
# -----------------------------
Write-Host "Transferring project to Raspberry Pi..."
$scpTarget = "$PiUser@$PiHost`:$RemotePath"
scp -r $LocalPath $scpTarget
# -----------------------------
# اجرای برنامه روی Raspberry Pi
# -----------------------------
Write-Host "Running program on Raspberry Pi..."
ssh "$PiUser@$PiHost" "cd $RemotePath && python3 $MainPy"
Write-Host "Deployment complete! Latest version is running on Raspberry Pi." -ForegroundColor Green

276
main.py Normal file
View File

@ -0,0 +1,276 @@
import os
import subprocess
import sys
from importlib.util import find_spec
from pathlib import Path
from tkinter import TclError
BASE_DIR = Path(__file__).resolve().parent
REQUIREMENTS_FILE = BASE_DIR / "requirements.txt"
PROJECT_VENV_DIR = BASE_DIR / ".runtime-venv"
REQUIRED_IMPORTS = {
"customtkinter": "customtkinter",
"cryptography": "cryptography",
"pyserial": "serial",
"arabic-reshaper": "arabic_reshaper",
"python-bidi": "bidi",
}
def _project_venv_python() -> Path:
if os.name == "nt":
return PROJECT_VENV_DIR / "Scripts" / "python.exe"
return PROJECT_VENV_DIR / "bin" / "python"
def _running_inside_virtualenv() -> bool:
return sys.prefix != getattr(sys, "base_prefix", sys.prefix) or hasattr(sys, "real_prefix")
def _missing_requirements() -> list[str]:
missing = []
for package_name, import_name in REQUIRED_IMPORTS.items():
if find_spec(import_name) is None:
missing.append(package_name)
return missing
def _missing_requirements_for_python(python_executable: str) -> tuple[list[str] | None, str | None]:
check_script = "\n".join(
[
"from importlib.util import find_spec",
f"required = {REQUIRED_IMPORTS!r}",
"missing = [name for name, import_name in required.items() if find_spec(import_name) is None]",
"print('\\n'.join(missing))",
]
)
ok, output = _run_subprocess(
[python_executable, "-c", check_script],
"Checking project virtual environment",
)
if not ok:
return None, output
return [line.strip() for line in output.splitlines() if line.strip()], None
def _run_subprocess(command: list[str], description: str) -> tuple[bool, str]:
try:
completed = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
)
except Exception as exc:
return False, f"{description} failed to start: {exc}"
if completed.returncode == 0:
return True, completed.stdout.strip()
details = completed.stderr.strip() or completed.stdout.strip() or "Unknown error"
return False, f"{description} failed: {details}"
def _ensure_pip_available(python_executable: str) -> tuple[bool, str | None]:
ok, message = _run_subprocess(
[python_executable, "-m", "pip", "--version"],
"Checking pip",
)
if ok:
return True, None
ok, ensure_message = _run_subprocess(
[python_executable, "-m", "ensurepip", "--upgrade"],
"Bootstrapping pip",
)
if ok:
return True, None
return False, ensure_message or message
def _install_requirements_with_python(python_executable: str) -> tuple[bool, str | None]:
if not REQUIREMENTS_FILE.exists():
return False, f"Requirements file not found: {REQUIREMENTS_FILE}"
pip_ready, pip_message = _ensure_pip_available(python_executable)
if not pip_ready:
return False, pip_message
print("Installing missing Python packages...", flush=True)
ok, message = _run_subprocess(
[
python_executable,
"-m",
"pip",
"install",
"--disable-pip-version-check",
"-r",
str(REQUIREMENTS_FILE),
],
"Installing requirements",
)
if not ok:
return False, message
return True, None
def _create_project_venv() -> tuple[bool, str | None]:
venv_python = _project_venv_python()
if venv_python.exists():
return True, None
print(f"Creating local virtual environment in {PROJECT_VENV_DIR}...", flush=True)
ok, message = _run_subprocess(
[sys.executable, "-m", "venv", str(PROJECT_VENV_DIR)],
"Creating virtual environment",
)
if ok and venv_python.exists():
return True, None
help_message = "\n".join(
[
message or "Creating virtual environment failed.",
"",
"On Raspberry Pi / Debian, install venv support first:",
" sudo apt install python3-venv python3-full",
]
)
return False, help_message
def _restart_inside_project_venv() -> tuple[bool, str | None]:
venv_python = _project_venv_python()
if not venv_python.exists():
return False, f"Virtual environment Python was not found: {venv_python}"
print("Restarting app inside the local virtual environment...", flush=True)
argv = [str(venv_python), str(BASE_DIR / "main.py"), *sys.argv[1:]]
env = os.environ.copy()
env["SECURE_SMS_RUNTIME_VENV"] = str(PROJECT_VENV_DIR)
try:
os.execve(str(venv_python), argv, env)
except Exception as exc:
return False, f"Restarting inside virtual environment failed: {exc}"
return True, None
def _ensure_runtime_dependencies() -> tuple[bool, str | None]:
missing = _missing_requirements()
if not missing:
return True, None
print(f"Missing packages detected: {', '.join(missing)}", flush=True)
if _running_inside_virtualenv():
ok, message = _install_requirements_with_python(sys.executable)
if not ok:
return False, message
else:
venv_python = _project_venv_python()
if venv_python.exists():
venv_missing, venv_message = _missing_requirements_for_python(str(venv_python))
if venv_message:
return False, venv_message
if not venv_missing:
ok, message = _restart_inside_project_venv()
if not ok:
return False, message
return False, "Unexpected bootstrap state while switching to the local virtual environment."
ok, message = _create_project_venv()
if not ok:
return False, message
ok, message = _install_requirements_with_python(str(venv_python))
if not ok:
return False, message
ok, message = _restart_inside_project_venv()
if not ok:
return False, message
return False, "Unexpected bootstrap state while switching to the local virtual environment."
remaining = _missing_requirements()
if remaining:
return False, f"Packages are still missing after installation: {', '.join(remaining)}"
return True, None
def _prepare_linux_display() -> tuple[bool, str | None]:
if not sys.platform.startswith("linux"):
return True, None
display = os.environ.get("DISPLAY")
if display:
return True, None
x11_socket = Path("/tmp/.X11-unix/X0")
if x11_socket.exists():
os.environ["DISPLAY"] = ":0"
return True, None
message = "\n".join(
[
"No GUI display was found for Tkinter.",
"This app needs a graphical desktop session on Raspberry Pi.",
"",
"If you are on the Pi screen, start the desktop first and then run the app.",
"If you run it from SSH/systemd, usually you need:",
" DISPLAY=:0",
" XAUTHORITY=/home/pars/.Xauthority",
"",
"Example:",
" export DISPLAY=:0",
" export XAUTHORITY=/home/pars/.Xauthority",
" python3 main.py",
]
)
return False, message
def _display_error_message(exc: Exception) -> str:
return "\n".join(
[
f"GUI startup failed: {exc}",
"",
"Tkinter could not connect to the Raspberry Pi graphical session.",
"Run the app from the Pi desktop session or set DISPLAY/XAUTHORITY correctly.",
"",
"Typical Raspberry Pi values:",
" DISPLAY=:0",
" XAUTHORITY=/home/pars/.Xauthority",
]
)
def main():
deps_ready, deps_message = _ensure_runtime_dependencies()
if not deps_ready:
print(deps_message, file=sys.stderr)
return 1
ready, message = _prepare_linux_display()
if not ready:
print(message, file=sys.stderr)
return 1
try:
from secure_sms.controller import AppController
from secure_sms.ui import SecureSmsApp
controller = AppController()
app = SecureSmsApp(controller)
controller.bind_ui(app)
app.protocol("WM_DELETE_WINDOW", controller.shutdown)
app.mainloop()
return 0
except TclError as exc:
print(_display_error_message(exc), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
customtkinter==5.2.2
cryptography==42.0.5
pyserial==3.5
arabic-reshaper==3.0.0
python-bidi==0.4.2

1
secure_sms/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Secure SMS application package."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

137
secure_sms/controller.py Normal file
View File

@ -0,0 +1,137 @@
from __future__ import annotations
from typing import Optional
from secure_sms.database import Database
from secure_sms.gsm import GSMGateway
from secure_sms.services import SecureMessagingService
class AppController:
def __init__(self):
self.db = Database()
self.service = SecureMessagingService(self.db)
self.gsm: Optional[GSMGateway] = None
self.ui = None
def bind_ui(self, ui):
self.ui = ui
def is_bootstrapped(self) -> bool:
return self.service.is_bootstrapped()
def bootstrap(self, password: str):
self.service.bootstrap(password)
self._start_gsm()
def unlock(self, password: str) -> bool:
unlocked = self.service.unlock(password)
if unlocked:
self._start_gsm()
return unlocked
def verify_password(self, password: str) -> bool:
return self.service.verify_password(password)
def change_master_password(self, current_password: str, new_password: str):
self.service.change_master_password(current_password, new_password)
def _start_gsm(self) -> bool:
self._stop_gsm()
port, baudrate = self.service.get_connection_settings()
self.gsm = GSMGateway(port=port, baudrate=baudrate, message_callback=self._on_sms_received)
connected = self.gsm.connect()
self._notify_ui()
return connected
def reconnect_modem(self, port: str, baudrate: int) -> bool:
self.service.update_connection_settings(port, baudrate)
return self._start_gsm()
def modem_status(self) -> dict:
return {
"connected": bool(self.gsm and self.gsm.is_connected),
"port": self.gsm.port if self.gsm else self.service.get_connection_settings()[0],
"baudrate": self.gsm.baudrate if self.gsm else self.service.get_connection_settings()[1],
}
def list_contacts(self):
return self.service.list_contacts()
def get_contact(self, phone: str):
return self.service.get_contact(phone)
def save_contact(self, name: str, phone: str):
self.service.add_or_update_contact(name, phone)
self._notify_ui()
def get_messages(self, phone: str):
return self.service.get_messages(phone)
def send_message(self, phone: str, text: str) -> tuple[bool, str]:
try:
frames, mode = self.service.prepare_outgoing_message(phone, text)
except Exception as exc:
return False, str(exc)
if self.gsm and self.gsm.is_connected:
sent = self.gsm.send_frames(phone, frames)
state = "sent" if sent else "failed"
else:
sent = True
state = "simulated"
if sent:
self.service.store_outgoing_message(phone, text, mode, state)
self._notify_ui(phone)
return True, state
return False, "failed"
def request_secure(self, phone: str) -> tuple[bool, str]:
frames = self.service.request_secure_channel(phone)
if self.gsm and self.gsm.is_connected:
ok = self.gsm.send_frames(phone, frames)
status = "sent" if ok else "failed"
else:
ok = True
status = "simulated"
self._notify_ui(phone)
return ok, status
def switch_to_normal(self, phone: str) -> tuple[bool, str]:
frames = self.service.request_normal_mode(phone)
if self.gsm and self.gsm.is_connected:
ok = self.gsm.send_frames(phone, frames)
status = "sent" if ok else "failed"
else:
ok = True
status = "simulated"
self._notify_ui(phone)
return ok, status
def get_admin_snapshot(self) -> dict:
snapshot = self.service.get_admin_snapshot()
snapshot["modem"] = self.modem_status()
return snapshot
def _on_sms_received(self, sender: str, raw_text: str):
try:
_, reply_frames = self.service.process_incoming_sms(sender, raw_text)
if reply_frames and self.gsm and self.gsm.is_connected:
self.gsm.send_frames(sender, reply_frames)
finally:
self._notify_ui(sender)
def _notify_ui(self, phone: Optional[str] = None):
if self.ui:
self.ui.after(0, lambda: self.ui.handle_background_refresh(phone))
def _stop_gsm(self):
if self.gsm:
self.gsm.disconnect()
self.gsm = None
def shutdown(self):
self._stop_gsm()
if self.ui:
self.ui.destroy()

410
secure_sms/database.py Normal file
View File

@ -0,0 +1,410 @@
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Optional
from secure_sms.security import SecurityMetadata, StorageCipher
DB_FILE = "secure_sms_v2.db"
def utc_now() -> str:
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
class Database:
def __init__(self, db_path: str = DB_FILE):
self.db_path = Path(db_path)
self._initialize()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def _initialize(self):
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS identity (
id INTEGER PRIMARY KEY CHECK(id = 1),
private_key_enc TEXT NOT NULL,
public_key_enc TEXT NOT NULL,
fingerprint TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS contacts (
phone TEXT PRIMARY KEY,
name_enc TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'normal',
secure_state TEXT NOT NULL DEFAULT 'none',
peer_public_key_enc TEXT,
peer_fingerprint TEXT,
last_secure_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL,
direction TEXT NOT NULL,
body_enc TEXT NOT NULL,
mode TEXT NOT NULL,
transport_state TEXT NOT NULL,
metadata_enc TEXT,
created_at TEXT NOT NULL
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS packet_fragments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL,
packet_id TEXT NOT NULL,
packet_kind TEXT NOT NULL,
packet_mode TEXT,
part_no INTEGER NOT NULL,
total_parts INTEGER NOT NULL,
chunk TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(phone, packet_id, part_no)
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS secure_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT,
event_type TEXT NOT NULL,
details_enc TEXT,
created_at TEXT NOT NULL
)
"""
)
conn.commit()
def is_bootstrapped(self) -> bool:
return self.get_security_metadata() is not None
def get_security_metadata(self) -> Optional[SecurityMetadata]:
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute("SELECT value FROM app_config WHERE key = 'password_salt'")
salt_row = cursor.fetchone()
cursor.execute("SELECT value FROM app_config WHERE key = 'password_verifier'")
verifier_row = cursor.fetchone()
if not salt_row or not verifier_row:
return None
return SecurityMetadata(salt=salt_row["value"], verifier=verifier_row["value"])
def set_security_metadata(self, meta: SecurityMetadata):
self.set_config("password_salt", meta.salt)
self.set_config("password_verifier", meta.verifier)
def set_config(self, key: str, value: str):
with self._connect() as conn:
conn.execute(
"INSERT INTO app_config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
conn.commit()
def get_config(self, key: str, default: Optional[str] = None) -> Optional[str]:
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute("SELECT value FROM app_config WHERE key = ?", (key,))
row = cursor.fetchone()
return row["value"] if row else default
def get_connection_settings(self) -> tuple[str, int]:
port = self.get_config("gsm_port", "COM1") or "COM1"
baudrate = int(self.get_config("gsm_baudrate", "115200") or "115200")
return port, baudrate
def set_connection_settings(self, port: str, baudrate: int):
self.set_config("gsm_port", port)
self.set_config("gsm_baudrate", str(baudrate))
def save_identity(self, private_key_enc: str, public_key_enc: str, fingerprint: str):
with self._connect() as conn:
conn.execute(
"""
INSERT INTO identity(id, private_key_enc, public_key_enc, fingerprint, created_at)
VALUES(1, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
private_key_enc = excluded.private_key_enc,
public_key_enc = excluded.public_key_enc,
fingerprint = excluded.fingerprint
""",
(private_key_enc, public_key_enc, fingerprint, utc_now()),
)
conn.commit()
def get_identity_row(self) -> Optional[sqlite3.Row]:
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM identity WHERE id = 1")
return cursor.fetchone()
def upsert_contact(self, phone: str, name_enc: str):
now = utc_now()
with self._connect() as conn:
conn.execute(
"""
INSERT INTO contacts(phone, name_enc, mode, secure_state, created_at, updated_at)
VALUES(?, ?, 'normal', 'none', ?, ?)
ON CONFLICT(phone) DO UPDATE SET
name_enc = excluded.name_enc,
updated_at = excluded.updated_at
""",
(phone, name_enc, now, now),
)
conn.commit()
def ensure_contact_exists(self, phone: str, name_enc: str):
now = utc_now()
with self._connect() as conn:
conn.execute(
"""
INSERT INTO contacts(phone, name_enc, mode, secure_state, created_at, updated_at)
VALUES(?, ?, 'normal', 'none', ?, ?)
ON CONFLICT(phone) DO NOTHING
""",
(phone, name_enc, now, now),
)
conn.commit()
def get_contact_row(self, phone: str) -> Optional[sqlite3.Row]:
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM contacts WHERE phone = ?", (phone,))
return cursor.fetchone()
def list_contact_rows(self) -> list[sqlite3.Row]:
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT
c.*,
m.body_enc AS last_body_enc
FROM contacts c
LEFT JOIN messages m
ON m.id = (
SELECT id FROM messages
WHERE phone = c.phone
ORDER BY id DESC
LIMIT 1
)
ORDER BY COALESCE(m.id, 0) DESC, c.updated_at DESC
"""
)
return cursor.fetchall()
def update_contact_security(
self,
phone: str,
*,
mode: Optional[str] = None,
secure_state: Optional[str] = None,
peer_public_key_enc: Optional[str] = None,
peer_fingerprint: Optional[str] = None,
last_secure_at: Optional[str] = None,
):
updates = []
values = []
if mode is not None:
updates.append("mode = ?")
values.append(mode)
if secure_state is not None:
updates.append("secure_state = ?")
values.append(secure_state)
if peer_public_key_enc is not None:
updates.append("peer_public_key_enc = ?")
values.append(peer_public_key_enc)
if peer_fingerprint is not None:
updates.append("peer_fingerprint = ?")
values.append(peer_fingerprint)
if last_secure_at is not None:
updates.append("last_secure_at = ?")
values.append(last_secure_at)
updates.append("updated_at = ?")
values.append(utc_now())
values.append(phone)
with self._connect() as conn:
conn.execute(
f"UPDATE contacts SET {', '.join(updates)} WHERE phone = ?",
values,
)
conn.commit()
def add_message(
self,
phone: str,
direction: str,
body_enc: str,
mode: str,
transport_state: str,
metadata_enc: Optional[str] = None,
) -> int:
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO messages(phone, direction, body_enc, mode, transport_state, metadata_enc, created_at)
VALUES(?, ?, ?, ?, ?, ?, ?)
""",
(phone, direction, body_enc, mode, transport_state, metadata_enc, utc_now()),
)
conn.commit()
return int(cursor.lastrowid)
def list_message_rows(self, phone: str) -> list[sqlite3.Row]:
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM messages WHERE phone = ? ORDER BY id ASC", (phone,))
return cursor.fetchall()
def log_secure_event(self, phone: Optional[str], event_type: str, details_enc: Optional[str]):
with self._connect() as conn:
conn.execute(
"INSERT INTO secure_events(phone, event_type, details_enc, created_at) VALUES(?, ?, ?, ?)",
(phone, event_type, details_enc, utc_now()),
)
conn.commit()
def list_secure_event_rows(self, limit: int = 50) -> list[sqlite3.Row]:
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM secure_events ORDER BY id DESC LIMIT ?", (limit,))
return cursor.fetchall()
def save_fragment(
self,
phone: str,
packet_id: str,
packet_kind: str,
packet_mode: Optional[str],
part_no: int,
total_parts: int,
chunk: str,
):
with self._connect() as conn:
conn.execute(
"""
INSERT INTO packet_fragments(phone, packet_id, packet_kind, packet_mode, part_no, total_parts, chunk, created_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(phone, packet_id, part_no) DO NOTHING
""",
(phone, packet_id, packet_kind, packet_mode, part_no, total_parts, chunk, utc_now()),
)
conn.commit()
def get_packet_fragments(self, phone: str, packet_id: str) -> list[sqlite3.Row]:
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT * FROM packet_fragments
WHERE phone = ? AND packet_id = ?
ORDER BY part_no ASC
""",
(phone, packet_id),
)
return cursor.fetchall()
def delete_packet_fragments(self, phone: str, packet_id: str):
with self._connect() as conn:
conn.execute("DELETE FROM packet_fragments WHERE phone = ? AND packet_id = ?", (phone, packet_id))
conn.commit()
def list_pending_packets(self) -> list[sqlite3.Row]:
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT
phone,
packet_id,
packet_kind,
packet_mode,
COUNT(*) AS received_parts,
MAX(total_parts) AS total_parts,
MIN(created_at) AS first_seen
FROM packet_fragments
GROUP BY phone, packet_id, packet_kind, packet_mode
ORDER BY MIN(created_at) DESC
"""
)
return cursor.fetchall()
def collect_stats(self) -> dict:
with self._connect() as conn:
cursor = conn.cursor()
stats = {}
cursor.execute("SELECT COUNT(*) AS count FROM contacts")
stats["contacts"] = cursor.fetchone()["count"]
cursor.execute("SELECT COUNT(*) AS count FROM contacts WHERE mode = 'secure'")
stats["secure_contacts"] = cursor.fetchone()["count"]
cursor.execute("SELECT COUNT(*) AS count FROM contacts WHERE secure_state = 'pending'")
stats["pending_contacts"] = cursor.fetchone()["count"]
cursor.execute("SELECT COUNT(*) AS count FROM messages")
stats["messages"] = cursor.fetchone()["count"]
cursor.execute("SELECT COUNT(*) AS count FROM messages WHERE mode = 'secure'")
stats["secure_messages"] = cursor.fetchone()["count"]
cursor.execute("SELECT COUNT(DISTINCT packet_id) AS count FROM packet_fragments")
stats["incomplete_packets"] = cursor.fetchone()["count"]
cursor.execute("SELECT COUNT(*) AS count FROM secure_events WHERE event_type = 'secure_established'")
stats["secure_connections"] = cursor.fetchone()["count"]
return stats
def rotate_encrypted_payloads(self, old_cipher: StorageCipher, new_cipher: StorageCipher):
table_map = {
"contacts": ("phone", ["name_enc", "peer_public_key_enc"]),
"messages": ("id", ["body_enc", "metadata_enc"]),
"secure_events": ("id", ["details_enc"]),
"identity": ("id", ["private_key_enc", "public_key_enc"]),
}
with self._connect() as conn:
cursor = conn.cursor()
for table_name, (pk_column, encrypted_columns) in table_map.items():
cursor.execute(f"SELECT * FROM {table_name}")
rows = cursor.fetchall()
for row in rows:
assignments = []
values = []
for column in encrypted_columns:
current_value = row[column]
if current_value is None:
continue
decrypted = old_cipher.decrypt_text(current_value)
assignments.append(f"{column} = ?")
values.append(new_cipher.encrypt_text(decrypted))
if not assignments:
continue
values.append(row[pk_column])
cursor.execute(
f"UPDATE {table_name} SET {', '.join(assignments)} WHERE {pk_column} = ?",
values,
)
conn.commit()

154
secure_sms/gsm.py Normal file
View File

@ -0,0 +1,154 @@
import re
import threading
import time
from typing import Callable, Optional
import serial
class GSMGateway:
def __init__(
self,
port: str,
baudrate: int,
message_callback: Optional[Callable[[str, str], None]] = None,
):
self.port = port
self.baudrate = baudrate
self.message_callback = message_callback
self.serial_conn = None
self.is_running = False
self.read_thread = None
self.lock = threading.Lock()
@property
def is_connected(self) -> bool:
return bool(self.serial_conn and self.serial_conn.is_open)
def connect(self) -> bool:
try:
self.serial_conn = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=1,
)
self.is_running = True
self.send_at_cmd("AT")
self.send_at_cmd("AT+CMGF=1")
self.send_at_cmd('AT+CNMI=2,1,0,0,0')
self.read_thread = threading.Thread(target=self._read_loop, daemon=True)
self.read_thread.start()
return True
except Exception:
self.serial_conn = None
self.is_running = False
return False
def disconnect(self):
self.is_running = False
if self.serial_conn and self.serial_conn.is_open:
self.serial_conn.close()
if self.read_thread:
self.read_thread.join(timeout=1.5)
def send_at_cmd(self, command: str, expected_response: str = "OK", timeout: float = 2.0) -> tuple[bool, list[str]]:
with self.lock:
if not self.is_connected:
return False, ["offline"]
self.serial_conn.reset_input_buffer()
self.serial_conn.write((command + "\r\n").encode("ascii"))
start = time.time()
lines = []
while time.time() - start < timeout:
if self.serial_conn.in_waiting:
line = self.serial_conn.readline().decode("ascii", errors="ignore").strip()
if line:
lines.append(line)
if expected_response in line or "ERROR" in line:
break
else:
time.sleep(0.05)
return expected_response in "\n".join(lines), lines
def send_frames(self, phone: str, frames: list[str]) -> bool:
if not self.is_connected:
return False
for frame in frames:
if not self._send_single_sms(phone, frame):
return False
time.sleep(0.8)
return True
def _send_single_sms(self, phone: str, body: str) -> bool:
with self.lock:
try:
self.serial_conn.reset_input_buffer()
self.serial_conn.write(f'AT+CMGS="{phone}"\r\n'.encode("ascii"))
start = time.time()
prompt_ready = False
while time.time() - start < 2:
if self.serial_conn.in_waiting:
char = self.serial_conn.read().decode("ascii", errors="ignore")
if char == ">":
prompt_ready = True
break
time.sleep(0.05)
if not prompt_ready:
self.serial_conn.write(chr(27).encode("ascii"))
return False
self.serial_conn.write(body.encode("ascii") + chr(26).encode("ascii"))
start = time.time()
while time.time() - start < 10:
if self.serial_conn.in_waiting:
line = self.serial_conn.readline().decode("ascii", errors="ignore").strip()
if "OK" in line:
return True
if "ERROR" in line:
return False
else:
time.sleep(0.1)
except Exception:
return False
return False
def _read_loop(self):
while self.is_running:
try:
if self.is_connected and self.serial_conn.in_waiting and self.lock.acquire(blocking=False):
try:
line = self.serial_conn.readline().decode("ascii", errors="ignore").strip()
if line.startswith("+CMTI:"):
match = re.search(r'\+CMTI:\s*".*?",(\d+)', line)
if match:
index = int(match.group(1))
threading.Thread(
target=self._process_incoming_sms,
args=(index,),
daemon=True,
).start()
finally:
self.lock.release()
except Exception:
pass
time.sleep(0.1)
def _process_incoming_sms(self, index: int):
time.sleep(0.7)
ok, lines = self.send_at_cmd(f"AT+CMGR={index}", expected_response="OK", timeout=3)
if not ok:
return
sender = "ناشناس"
body_lines = []
reading_body = False
for line in lines:
if line.startswith("+CMGR:"):
parts = line.split(",")
if len(parts) >= 2:
sender = parts[1].strip('"')
reading_body = True
continue
if reading_body and line not in {"OK", "ERROR"}:
body_lines.append(line)
if self.message_callback:
self.message_callback(sender, "\n".join(body_lines))
self.send_at_cmd(f"AT+CMGD={index}")

53
secure_sms/models.py Normal file
View File

@ -0,0 +1,53 @@
from dataclasses import dataclass
from typing import Optional
@dataclass
class ContactSummary:
phone: str
name: str
mode: str
secure_state: str
has_peer_key: bool
last_message_preview: str = ""
@dataclass
class ContactDetails:
phone: str
name: str
mode: str
secure_state: str
peer_fingerprint: Optional[str]
has_peer_key: bool
last_secure_at: Optional[str]
@dataclass
class MessageView:
id: int
phone: str
direction: str
body: str
mode: str
transport_state: str
created_at: str
@dataclass
class SecureEventView:
created_at: str
event_type: str
phone: str
details: str
@dataclass
class PendingPacketView:
phone: str
packet_id: str
packet_kind: str
packet_mode: Optional[str]
received_parts: int
total_parts: int
first_seen: str

94
secure_sms/protocol.py Normal file
View File

@ -0,0 +1,94 @@
import json
import uuid
from dataclasses import dataclass
from typing import Optional
from secure_sms.security import b64u_decode, b64u_encode
FRAME_PREFIX = "@SSM1"
FRAME_CHUNK_SIZE = 92
@dataclass
class ParsedFrame:
category: str
packet_id: str
part_no: int
total_parts: int
chunk: str
mode: Optional[str] = None
def _split_payload(encoded_payload: str) -> list[str]:
return [
encoded_payload[index:index + FRAME_CHUNK_SIZE]
for index in range(0, len(encoded_payload), FRAME_CHUNK_SIZE)
] or [""]
def encode_plain_body(text: str) -> str:
return b64u_encode(text.encode("utf-8"))
def decode_plain_body(encoded_text: str) -> str:
return b64u_decode(encoded_text).decode("utf-8")
def encode_control_payload(payload: dict) -> str:
packed = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
return b64u_encode(packed)
def decode_control_payload(encoded_payload: str) -> dict:
return json.loads(b64u_decode(encoded_payload).decode("utf-8"))
def build_control_frames(payload: dict, packet_id: Optional[str] = None) -> list[str]:
packet_id = packet_id or uuid.uuid4().hex[:10]
encoded_payload = encode_control_payload(payload)
parts = _split_payload(encoded_payload)
return [
f"{FRAME_PREFIX}|CTL|{packet_id}|{index + 1}|{len(parts)}|{chunk}"
for index, chunk in enumerate(parts)
]
def build_message_frames(mode: str, encoded_payload: str, packet_id: Optional[str] = None) -> list[str]:
packet_id = packet_id or uuid.uuid4().hex[:10]
parts = _split_payload(encoded_payload)
return [
f"{FRAME_PREFIX}|MSG|{mode}|{packet_id}|{index + 1}|{len(parts)}|{chunk}"
for index, chunk in enumerate(parts)
]
def parse_frame(raw_text: str) -> Optional[ParsedFrame]:
if not raw_text.startswith(FRAME_PREFIX):
return None
if raw_text.startswith(f"{FRAME_PREFIX}|CTL|"):
parts = raw_text.split("|", 5)
if len(parts) != 6:
return None
_, _, packet_id, part_no, total_parts, chunk = parts
return ParsedFrame(
category="control",
packet_id=packet_id,
part_no=int(part_no),
total_parts=int(total_parts),
chunk=chunk,
)
if raw_text.startswith(f"{FRAME_PREFIX}|MSG|"):
parts = raw_text.split("|", 6)
if len(parts) != 7:
return None
_, _, mode, packet_id, part_no, total_parts, chunk = parts
return ParsedFrame(
category="message",
mode=mode,
packet_id=packet_id,
part_no=int(part_no),
total_parts=int(total_parts),
chunk=chunk,
)
return None

133
secure_sms/security.py Normal file
View File

@ -0,0 +1,133 @@
import base64
import hashlib
import os
from dataclasses import dataclass
from typing import Optional
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import x25519
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
def b64u_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def b64u_decode(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode((value + padding).encode("ascii"))
@dataclass
class SecurityMetadata:
salt: str
verifier: str
class PasswordManager:
def create_metadata(self, password: str) -> SecurityMetadata:
salt = os.urandom(16)
key = self.derive_key(password, b64u_encode(salt))
return SecurityMetadata(
salt=b64u_encode(salt),
verifier=hashlib.sha256(key).hexdigest(),
)
def derive_key(self, password: str, salt_b64: str) -> bytes:
kdf = Scrypt(
salt=b64u_decode(salt_b64),
length=32,
n=2**14,
r=8,
p=1,
)
return kdf.derive(password.encode("utf-8"))
def verify_password(self, password: str, meta: SecurityMetadata) -> bool:
key = self.derive_key(password, meta.salt)
return hashlib.sha256(key).hexdigest() == meta.verifier
class StorageCipher:
def __init__(self, key: bytes):
self._aes = AESGCM(key)
def encrypt_text(self, value: Optional[str]) -> Optional[str]:
if value is None:
return None
nonce = os.urandom(12)
payload = self._aes.encrypt(nonce, value.encode("utf-8"), None)
return "enc1:" + b64u_encode(nonce + payload)
def decrypt_text(self, value: Optional[str]) -> Optional[str]:
if value in (None, ""):
return value
if not value.startswith("enc1:"):
return value
raw = b64u_decode(value[5:])
nonce = raw[:12]
ciphertext = raw[12:]
plaintext = self._aes.decrypt(nonce, ciphertext, None)
return plaintext.decode("utf-8")
class ECCCryptoService:
INFO = b"sms-secure-channel-v2"
def generate_identity(self) -> tuple[str, str, str]:
private_key = x25519.X25519PrivateKey.generate()
public_key = private_key.public_key()
private_raw = private_key.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
public_raw = public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
public_b64 = b64u_encode(public_raw)
return b64u_encode(private_raw), public_b64, self.fingerprint_public_key(public_b64)
def fingerprint_public_key(self, public_key_b64: str) -> str:
digest = hashlib.sha256(b64u_decode(public_key_b64)).hexdigest()
return digest[:16].upper()
def encrypt_for_peer(self, message: str, peer_public_key_b64: str) -> str:
peer_public = x25519.X25519PublicKey.from_public_bytes(b64u_decode(peer_public_key_b64))
ephemeral_private = x25519.X25519PrivateKey.generate()
ephemeral_public = ephemeral_private.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
shared_key = ephemeral_private.exchange(peer_public)
derived_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=self.INFO,
).derive(shared_key)
nonce = os.urandom(12)
ciphertext = AESGCM(derived_key).encrypt(nonce, message.encode("utf-8"), None)
return b64u_encode(ephemeral_public + nonce + ciphertext)
def decrypt_from_peer(self, payload_b64: str, private_key_b64: str) -> str:
payload = b64u_decode(payload_b64)
if len(payload) < 60:
raise ValueError("Secure payload is too short.")
ephemeral_public_raw = payload[:32]
nonce = payload[32:44]
ciphertext = payload[44:]
private_key = x25519.X25519PrivateKey.from_private_bytes(b64u_decode(private_key_b64))
ephemeral_public = x25519.X25519PublicKey.from_public_bytes(ephemeral_public_raw)
shared_key = private_key.exchange(ephemeral_public)
derived_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=self.INFO,
).derive(shared_key)
plaintext = AESGCM(derived_key).decrypt(nonce, ciphertext, None)
return plaintext.decode("utf-8")

407
secure_sms/services.py Normal file
View File

@ -0,0 +1,407 @@
import platform
from pathlib import Path
from typing import Optional
from secure_sms.database import Database, utc_now
from secure_sms.models import ContactDetails, ContactSummary, MessageView, PendingPacketView, SecureEventView
from secure_sms.protocol import (
build_control_frames,
build_message_frames,
decode_control_payload,
decode_plain_body,
encode_plain_body,
parse_frame,
)
from secure_sms.security import ECCCryptoService, PasswordManager, StorageCipher
SYSTEM_CONTACT_LABEL = "مخاطب ناشناس"
class SecureMessagingService:
def __init__(self, db: Database):
self.db = db
self.password_manager = PasswordManager()
self.crypto = ECCCryptoService()
self.cipher: Optional[StorageCipher] = None
self.identity = None
@property
def unlocked(self) -> bool:
return self.cipher is not None and self.identity is not None
def is_bootstrapped(self) -> bool:
return self.db.is_bootstrapped()
def bootstrap(self, password: str):
if self.db.is_bootstrapped():
raise ValueError("Application is already configured.")
meta = self.password_manager.create_metadata(password)
key = self.password_manager.derive_key(password, meta.salt)
self.cipher = StorageCipher(key)
private_key, public_key, fingerprint = self.crypto.generate_identity()
self.db.set_security_metadata(meta)
self.db.save_identity(
private_key_enc=self.cipher.encrypt_text(private_key),
public_key_enc=self.cipher.encrypt_text(public_key),
fingerprint=fingerprint,
)
self.db.set_connection_settings("COM1", 115200)
self.identity = {
"private_key": private_key,
"public_key": public_key,
"fingerprint": fingerprint,
}
self.db.log_secure_event(None, "app_bootstrap", self._enc("راه‌اندازی اولیه برنامه انجام شد."))
def unlock(self, password: str) -> bool:
meta = self.db.get_security_metadata()
if not meta:
raise ValueError("Application is not configured.")
if not self.password_manager.verify_password(password, meta):
return False
key = self.password_manager.derive_key(password, meta.salt)
self.cipher = StorageCipher(key)
identity_row = self.db.get_identity_row()
if identity_row is None:
raise ValueError("Secure identity was not found.")
self.identity = {
"private_key": self.cipher.decrypt_text(identity_row["private_key_enc"]),
"public_key": self.cipher.decrypt_text(identity_row["public_key_enc"]),
"fingerprint": identity_row["fingerprint"],
}
return True
def verify_password(self, password: str) -> bool:
meta = self.db.get_security_metadata()
if not meta:
return False
return self.password_manager.verify_password(password, meta)
def change_master_password(self, current_password: str, new_password: str):
meta = self.db.get_security_metadata()
if not meta or not self.password_manager.verify_password(current_password, meta):
raise ValueError("رمز فعلی درست نیست.")
old_key = self.password_manager.derive_key(current_password, meta.salt)
new_meta = self.password_manager.create_metadata(new_password)
new_key = self.password_manager.derive_key(new_password, new_meta.salt)
old_cipher = StorageCipher(old_key)
new_cipher = StorageCipher(new_key)
self.db.rotate_encrypted_payloads(old_cipher, new_cipher)
self.db.set_security_metadata(new_meta)
self.cipher = new_cipher
identity_row = self.db.get_identity_row()
self.identity = {
"private_key": self.cipher.decrypt_text(identity_row["private_key_enc"]),
"public_key": self.cipher.decrypt_text(identity_row["public_key_enc"]),
"fingerprint": identity_row["fingerprint"],
}
self.db.log_secure_event(None, "password_changed", self._enc("رمز اصلی برنامه تغییر کرد."))
def _enc(self, value: Optional[str]) -> Optional[str]:
if self.cipher is None:
raise RuntimeError("Application is locked.")
return self.cipher.encrypt_text(value)
def _dec(self, value: Optional[str]) -> Optional[str]:
if self.cipher is None:
raise RuntimeError("Application is locked.")
return self.cipher.decrypt_text(value)
def add_or_update_contact(self, name: str, phone: str):
self.db.upsert_contact(phone, self._enc(name))
def ensure_contact(self, phone: str, fallback_name: Optional[str] = None):
fallback = fallback_name or SYSTEM_CONTACT_LABEL
self.db.ensure_contact_exists(phone, self._enc(fallback))
def list_contacts(self) -> list[ContactSummary]:
contacts = []
for row in self.db.list_contact_rows():
preview = self._dec(row["last_body_enc"]) if row["last_body_enc"] else ""
if preview:
preview = preview.replace("\n", " ").strip()
contacts.append(
ContactSummary(
phone=row["phone"],
name=self._dec(row["name_enc"]) or SYSTEM_CONTACT_LABEL,
mode=row["mode"],
secure_state=row["secure_state"],
has_peer_key=bool(row["peer_public_key_enc"]),
last_message_preview=(preview[:38] + "...") if preview and len(preview) > 38 else (preview or ""),
)
)
return contacts
def get_contact(self, phone: str) -> Optional[ContactDetails]:
row = self.db.get_contact_row(phone)
if not row:
return None
return ContactDetails(
phone=row["phone"],
name=self._dec(row["name_enc"]) or SYSTEM_CONTACT_LABEL,
mode=row["mode"],
secure_state=row["secure_state"],
peer_fingerprint=row["peer_fingerprint"],
has_peer_key=bool(row["peer_public_key_enc"]),
last_secure_at=row["last_secure_at"],
)
def get_messages(self, phone: str) -> list[MessageView]:
return [
MessageView(
id=row["id"],
phone=row["phone"],
direction=row["direction"],
body=self._dec(row["body_enc"]) or "",
mode=row["mode"],
transport_state=row["transport_state"],
created_at=row["created_at"],
)
for row in self.db.list_message_rows(phone)
]
def get_public_identity(self) -> dict:
if not self.identity:
raise RuntimeError("Application is locked.")
return {
"public_key": self.identity["public_key"],
"fingerprint": self.identity["fingerprint"],
}
def prepare_outgoing_message(self, phone: str, text: str) -> tuple[list[str], str]:
contact = self.db.get_contact_row(phone)
if not contact:
raise ValueError("مخاطب پیدا نشد.")
mode = contact["mode"]
if mode == "secure":
peer_key = self._dec(contact["peer_public_key_enc"])
if not peer_key:
raise ValueError("برای این مخاطب کلید امن وجود ندارد.")
encoded_payload = self.crypto.encrypt_for_peer(text, peer_key)
return build_message_frames("S", encoded_payload), "secure"
encoded_payload = encode_plain_body(text)
return build_message_frames("N", encoded_payload), "normal"
def store_outgoing_message(self, phone: str, text: str, mode: str, transport_state: str):
self.db.add_message(
phone=phone,
direction="out",
body_enc=self._enc(text),
mode=mode,
transport_state=transport_state,
)
def request_secure_channel(self, phone: str) -> list[str]:
self.ensure_contact(phone)
payload = {
"type": "hello",
"public_key": self.identity["public_key"],
"fingerprint": self.identity["fingerprint"],
"ts": utc_now(),
}
self.db.update_contact_security(phone, mode="normal", secure_state="pending")
self.db.log_secure_event(phone, "hello_sent", self._enc("درخواست ارتباط امن ارسال شد."))
self.db.add_message(
phone=phone,
direction="system",
body_enc=self._enc("درخواست ارتباط امن برای مخاطب ارسال شد."),
mode="system",
transport_state="local",
)
return build_control_frames(payload)
def request_normal_mode(self, phone: str) -> list[str]:
self.ensure_contact(phone)
payload = {
"type": "normal_mode",
"ts": utc_now(),
}
self.db.update_contact_security(phone, mode="normal", secure_state="ready")
self.db.log_secure_event(phone, "normal_mode_sent", self._enc("بازگشت به حالت عادی برای مخاطب ارسال شد."))
self.db.add_message(
phone=phone,
direction="system",
body_enc=self._enc("گفتگو به حالت عادی برگشت."),
mode="system",
transport_state="local",
)
return build_control_frames(payload)
def process_incoming_sms(self, sender: str, raw_text: str) -> tuple[str, Optional[list[str]]]:
self.ensure_contact(sender)
frame = parse_frame(raw_text)
if not frame:
self.db.add_message(
phone=sender,
direction="in",
body_enc=self._enc(raw_text),
mode="normal",
transport_state="received_raw",
)
return sender, None
payload = self._store_or_assemble_frame(sender, frame)
if payload is None:
self.db.log_secure_event(
sender,
"packet_fragment_received",
self._enc(f"بسته {frame.packet_id} در حال تکمیل است ({frame.part_no}/{frame.total_parts})."),
)
return sender, None
if frame.category == "control":
return sender, self._handle_control_payload(sender, payload)
else:
self._handle_message_payload(sender, frame.mode or "N", payload)
return sender, None
def _store_or_assemble_frame(self, sender: str, frame) -> Optional[str]:
if frame.total_parts == 1:
return frame.chunk
self.db.save_fragment(
sender,
frame.packet_id,
frame.category,
frame.mode,
frame.part_no,
frame.total_parts,
frame.chunk,
)
fragments = self.db.get_packet_fragments(sender, frame.packet_id)
if len(fragments) < frame.total_parts:
return None
payload = "".join(fragment["chunk"] for fragment in fragments)
self.db.delete_packet_fragments(sender, frame.packet_id)
return payload
def _handle_control_payload(self, sender: str, payload: str) -> Optional[list[str]]:
data = decode_control_payload(payload)
action = data.get("type")
if action == "hello":
public_key = data.get("public_key")
fingerprint = data.get("fingerprint") or self.crypto.fingerprint_public_key(public_key)
self.db.update_contact_security(
sender,
mode="secure",
secure_state="ready",
peer_public_key_enc=self._enc(public_key),
peer_fingerprint=fingerprint,
last_secure_at=utc_now(),
)
self.db.log_secure_event(sender, "hello_received", self._enc("درخواست ارتباط امن دریافت شد."))
self.db.log_secure_event(sender, "secure_established", self._enc("ارتباط امن برقرار شد."))
self.db.add_message(
phone=sender,
direction="system",
body_enc=self._enc("ارتباط امن با این مخاطب فعال شد."),
mode="system",
transport_state="local",
)
reply = {
"type": "hello_ack",
"public_key": self.identity["public_key"],
"fingerprint": self.identity["fingerprint"],
"ts": utc_now(),
}
return build_control_frames(reply)
elif action == "hello_ack":
public_key = data.get("public_key")
fingerprint = data.get("fingerprint") or self.crypto.fingerprint_public_key(public_key)
self.db.update_contact_security(
sender,
mode="secure",
secure_state="ready",
peer_public_key_enc=self._enc(public_key),
peer_fingerprint=fingerprint,
last_secure_at=utc_now(),
)
self.db.log_secure_event(sender, "hello_ack_received", self._enc("پاسخ ارتباط امن دریافت شد."))
self.db.log_secure_event(sender, "secure_established", self._enc("ارتباط امن برقرار شد."))
self.db.add_message(
phone=sender,
direction="system",
body_enc=self._enc("ارتباط امن آماده استفاده است."),
mode="system",
transport_state="local",
)
return None
elif action == "normal_mode":
self.db.update_contact_security(sender, mode="normal", secure_state="ready")
self.db.log_secure_event(sender, "normal_mode_received", self._enc("مخاطب گفتگو را به حالت عادی برگرداند."))
self.db.add_message(
phone=sender,
direction="system",
body_enc=self._enc("مخاطب گفتگو را به حالت عادی برگرداند."),
mode="system",
transport_state="local",
)
return None
return None
def _handle_message_payload(self, sender: str, mode_marker: str, payload: str):
if mode_marker == "S":
try:
body = self.crypto.decrypt_from_peer(payload, self.identity["private_key"])
mode = "secure"
transport_state = "received_secure"
except Exception:
body = "پیام امن دریافت شد اما بازگشایی نشد."
mode = "secure"
transport_state = "decrypt_failed"
self.db.log_secure_event(sender, "decrypt_failed", self._enc("بازگشایی پیام امن ناموفق بود."))
else:
body = decode_plain_body(payload)
mode = "normal"
transport_state = "received"
self.db.add_message(
phone=sender,
direction="in",
body_enc=self._enc(body),
mode=mode,
transport_state=transport_state,
)
def get_admin_snapshot(self) -> dict:
stats = self.db.collect_stats()
identity = self.get_public_identity()
return {
"stats": stats,
"events": [
SecureEventView(
created_at=row["created_at"],
event_type=row["event_type"],
phone=row["phone"] or "-",
details=self._dec(row["details_enc"]) or "",
)
for row in self.db.list_secure_event_rows()
],
"pending_packets": [
PendingPacketView(
phone=row["phone"],
packet_id=row["packet_id"],
packet_kind=row["packet_kind"],
packet_mode=row["packet_mode"],
received_parts=row["received_parts"],
total_parts=row["total_parts"],
first_seen=row["first_seen"],
)
for row in self.db.list_pending_packets()
],
"system_info": {
"platform": platform.platform(),
"python": platform.python_version(),
"db_path": str(Path(self.db.db_path).resolve()),
"modem_port": self.db.get_connection_settings()[0],
"baudrate": self.db.get_connection_settings()[1],
"fingerprint": identity["fingerprint"],
},
}
def get_connection_settings(self) -> tuple[str, int]:
return self.db.get_connection_settings()
def update_connection_settings(self, port: str, baudrate: int):
self.db.set_connection_settings(port, baudrate)
self.db.log_secure_event(None, "connection_settings_changed", self._enc(f"تنظیمات مودم به {port}/{baudrate} تغییر کرد."))

1509
secure_sms/ui.py Normal file

File diff suppressed because it is too large Load Diff

BIN
secure_sms_v2.db Normal file

Binary file not shown.