Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,959 changes: 2,959 additions & 0 deletions bot.py

Large diffs are not rendered by default.

510 changes: 510 additions & 0 deletions config.py

Large diffs are not rendered by default.

154 changes: 154 additions & 0 deletions crypto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""MeshCore payload crypto: key derivation and DM/channel decryption."""

import hashlib
import hmac
import sys
from dataclasses import dataclass
from typing import Optional

try:
import nacl.bindings
from Crypto.Cipher import AES
except ImportError:
sys.stderr.write(
"pynacl and pycryptodome are required. "
"run: pip install -r requirements.txt\n"
)
sys.exit(1)

def _clamp_scalar(k: bytes) -> bytes:
b = bytearray(k[:32])
b[0] &= 248
b[31] &= 63
b[31] |= 64
return bytes(b)


def derive_public_key(private_key: bytes) -> bytes:
return nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(private_key[:32])


def derive_shared_secret(our_private_key: bytes, their_public_key: bytes) -> bytes:
clamped = _clamp_scalar(our_private_key[:32])
x25519_pub = nacl.bindings.crypto_sign_ed25519_pk_to_curve25519(their_public_key)
return nacl.bindings.crypto_scalarmult(clamped, x25519_pub)


@dataclass
class DecryptedDM:
timestamp: int
flags: int
message: str
dest_byte: int
src_byte: int
txt_type: int


@dataclass
class DecryptedChannel:
timestamp: int
flags: int
sender: Optional[str]
message: str
channel_hash: int


def decrypt_direct_message(
payload: bytes, shared_secret: bytes
) -> Optional[DecryptedDM]:
if len(payload) < 4:
return None
dest_byte = payload[0]
src_byte = payload[1]
mac = payload[2:4]
ciphertext = payload[4:]
if not ciphertext or len(ciphertext) % 16 != 0:
return None
if hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2] != mac:
return None
try:
decrypted = AES.new(shared_secret[:16], AES.MODE_ECB).decrypt(ciphertext)
except Exception:
return None
if len(decrypted) < 5:
return None
ts = int.from_bytes(decrypted[0:4], "little")
flags = decrypted[4]
txt_type = flags >> 2
body = decrypted[5:]
if txt_type == 2: # signed
if len(body) < 4:
return None
body = body[4:]
try:
text = body.decode("utf-8")
except UnicodeDecodeError:
return None
nul = text.find("\x00")
if nul >= 0:
text = text[:nul]
return DecryptedDM(
timestamp=ts, flags=flags, message=text,
dest_byte=dest_byte, src_byte=src_byte, txt_type=txt_type,
)


def try_decrypt_dm(
payload: bytes, our_private_key: bytes,
their_public_key: bytes, our_pubkey_byte: int,
) -> Optional[DecryptedDM]:
if len(payload) < 4:
return None
if payload[0] != our_pubkey_byte:
return None
if payload[1] != their_public_key[0]:
return None
try:
shared = derive_shared_secret(our_private_key, their_public_key)
except Exception:
return None
return decrypt_direct_message(payload, shared)


def decrypt_group_text(
payload: bytes, channel_secret: bytes
) -> Optional[DecryptedChannel]:
if len(payload) < 3:
return None
channel_hash = payload[0]
cipher_mac = payload[1:3]
ciphertext = payload[3:]
if not ciphertext or len(ciphertext) % 16 != 0:
return None
# meshcore channel HMAC uses key + 16 zero bytes
full_secret = channel_secret + bytes(16)
if hmac.new(full_secret, ciphertext, hashlib.sha256).digest()[:2] != cipher_mac:
return None
try:
decrypted = AES.new(channel_secret, AES.MODE_ECB).decrypt(ciphertext)
except Exception:
return None
if len(decrypted) < 5:
return None
ts = int.from_bytes(decrypted[0:4], "little")
flags = decrypted[4]
try:
text = decrypted[5:].decode("utf-8")
except UnicodeDecodeError:
return None
nul = text.find("\x00")
if nul >= 0:
text = text[:nul]
sender = None
content = text
colon = text.find(": ")
if 0 < colon < 50:
candidate = text[:colon]
if not any(c in candidate for c in ":[]\x00"):
sender = candidate
content = text[colon + 2:]
return DecryptedChannel(
timestamp=ts, flags=flags, sender=sender,
message=content, channel_hash=channel_hash,
)

239 changes: 239 additions & 0 deletions db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"""sqlite schema and the lock-guarded async DB wrapper for mcbot."""

import asyncio
import logging
import sqlite3
from pathlib import Path

# ---------------------------------------------------------------------------
# Database
#
SCHEMA = """
CREATE TABLE IF NOT EXISTS contacts (
public_key TEXT PRIMARY KEY,
adv_name TEXT,
type INTEGER,
flags INTEGER,
out_path TEXT,
out_path_len INTEGER,
out_path_hash_mode INTEGER,
adv_lat REAL,
adv_lon REAL,
last_advert INTEGER,
lastmod INTEGER,
first_seen_at INTEGER,
last_synced_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_contacts_prefix
ON contacts(substr(public_key,1,12));
CREATE INDEX IF NOT EXISTS idx_contacts_advname ON contacts(adv_name);

CREATE TABLE IF NOT EXISTS channels (
channel_idx INTEGER PRIMARY KEY,
name TEXT,
secret_hex TEXT,
last_synced_at INTEGER
);

CREATE TABLE IF NOT EXISTS channel_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_idx INTEGER,
channel_name TEXT,
sender_name TEXT,
sender_pubkey TEXT,
text TEXT,
sender_timestamp INTEGER,
path_len INTEGER,
path_hash_mode INTEGER,
path TEXT,
txt_type INTEGER,
snr REAL,
rssi INTEGER,
attempt INTEGER,
recv_time INTEGER,
received_at INTEGER,
is_outgoing INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_chanmsg_ch
ON channel_messages(channel_idx, id);

CREATE TABLE IF NOT EXISTS direct_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_pubkey_prefix TEXT,
sender_pubkey TEXT,
sender_name TEXT,
text TEXT,
sender_timestamp INTEGER,
path_len INTEGER,
path_hash_mode INTEGER,
txt_type INTEGER,
snr REAL,
signature TEXT,
received_at INTEGER,
is_outgoing INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_dm_prefix
ON direct_messages(sender_pubkey_prefix, id);

CREATE TABLE IF NOT EXISTS received_packets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at INTEGER,
event_type TEXT,
packet_type TEXT,
sender_pubkey_prefix TEXT,
sender_pubkey TEXT,
sender_name TEXT,
path TEXT,
path_len INTEGER,
snr REAL,
rssi INTEGER,
channel_idx INTEGER,
text TEXT,
payload_json TEXT,
attributes_json TEXT,
raw_hex TEXT -- on-air RF bytes (RX_LOG_DATA only)
);

CREATE TABLE IF NOT EXISTS device_info (
key TEXT PRIMARY KEY,
value TEXT,
last_updated INTEGER
);

CREATE TABLE IF NOT EXISTS bot_meta (
key TEXT PRIMARY KEY,
value TEXT
);

-- Named SQL snippets saved from the web Database console.
CREATE TABLE IF NOT EXISTS saved_queries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
query TEXT NOT NULL,
created_at INTEGER,
updated_at INTEGER
);

CREATE TABLE IF NOT EXISTS bot_groups (
name TEXT PRIMARY KEY,
description TEXT,
is_system INTEGER DEFAULT 0,
created_at INTEGER,
-- all_users=1 means every user is implicitly a member of this group
-- (the "*" member). 'public' is seeded this way so public-granted
-- commands are open to everyone
all_users INTEGER DEFAULT 0
);

CREATE TABLE IF NOT EXISTS bot_group_commands (
group_name TEXT NOT NULL,
command TEXT NOT NULL,
PRIMARY KEY (group_name, command),
FOREIGN KEY (group_name) REFERENCES bot_groups(name) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS bot_users (
pubkey TEXT PRIMARY KEY,
name TEXT,
added_by TEXT,
added_at INTEGER,
notes TEXT
);

CREATE TABLE IF NOT EXISTS bot_user_groups (
pubkey TEXT NOT NULL,
group_name TEXT NOT NULL,
PRIMARY KEY (pubkey, group_name),
FOREIGN KEY (pubkey) REFERENCES bot_users(pubkey) ON DELETE CASCADE,
FOREIGN KEY (group_name) REFERENCES bot_groups(name) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS bot_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER,
actor_pubkey TEXT,
actor_name TEXT,
action TEXT,
target TEXT,
detail TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON bot_audit_log(ts DESC);

CREATE TABLE IF NOT EXISTS command_cooldowns (
pubkey TEXT,
command TEXT,
last_used_at REAL,
PRIMARY KEY (pubkey, command)
);

-- Per-command runtime config. Authorization is NOT stored here: it is
-- groups-only and fail-closed — a command runs iff it is granted to a group
-- the caller belongs to (or to an all-users group such as 'public'). See
-- is_authorized_for_command().
CREATE TABLE IF NOT EXISTS command_config (
command TEXT PRIMARY KEY,
enabled INTEGER DEFAULT 1,
cooldown_seconds INTEGER,
allowed_channels TEXT, -- JSON array of names/indexes
triggers TEXT, -- JSON array of trigger strings
description TEXT,
allow_dm INTEGER,
dm_only INTEGER
);
"""


class DB:
def __init__(self, path: Path, log: logging.Logger):
self.path = path
self.log = log
self.conn = sqlite3.connect(
str(path), timeout=10.0, check_same_thread=False
)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA foreign_keys=ON")
self.conn.executescript(SCHEMA)
self.conn.commit()
self.lock = asyncio.Lock()

async def execute(self, sql: str, params: tuple = ()):
async with self.lock:
cur = self.conn.execute(sql, params)
self.conn.commit()
return cur

async def fetchone(self, sql: str, params: tuple = ()):
async with self.lock:
return self.conn.execute(sql, params).fetchone()

async def fetchall(self, sql: str, params: tuple = ()):
async with self.lock:
return self.conn.execute(sql, params).fetchall()

async def run_raw(self, sql: str) -> dict:
"""Execute one arbitrary SQL statement for the web Database console.
sqlite3 permits only a single statement per call, which conveniently
blocks ';'-chained injection. Returns column names + rows for a result
set (BLOBs hex-encoded so the payload is JSON-safe), else the affected
rowcount (-1 for statements like DDL that report none)."""
async with self.lock:
cur = self.conn.execute(sql)
if cur.description is not None:
columns = [d[0] for d in cur.description]
rows = [
[v.hex() if isinstance(v, (bytes, bytearray)) else v
for v in r]
for r in cur.fetchall()
]
self.conn.commit()
return {"columns": columns, "rows": rows, "rowcount": len(rows)}
rowcount = cur.rowcount
self.conn.commit()
return {"columns": [], "rows": [], "rowcount": rowcount}

def close(self):
try:
self.conn.close()
except Exception:
pass
Loading
Loading