Saba-python/secure_sms/database.py
2026-03-23 19:29:24 +03:30

411 lines
16 KiB
Python

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