feat!: modularize into orac/ package, add Public channel, deduplicate

Split 1424-line monolith into orac/ package with clean separation:
- constants.py — bot identity, radio config, triggers, limits
- crypto.py — Ed25519 identity, channel/peer crypto, ECDH
- meshcore.py — packet parsing/building, routing, dedup, node registry
- ai.py — Claude API integration, query extraction
- state.py — JSON persistence, history tracking
- bot.py — main loop, packet dispatch, connection management

Removed duplicated donglora library code (cobs_frame, read_frame,
encode_radio_config, encode_command, decode_response) — now uses
donglora.send/recv/connect directly.

Added MeshCore Public channel (fixed PSK 8b3387e9...cd72) and
expanded built-in hashtag channels: #nodeops, #repeater-ops, #test,
#weather, #protest, #general, #bot, #net, #emcomm.

Replaced ANSI print() with stdlib logging (color formatter for TTY).
Fixed dependency (donglora-python -> donglora). Strict ruff linting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.6 committed 2026-04-08 13:34:18 -06:00
1 parent 9130b3c478
commit 9e6f171c5d
11 files changed
+2350 -1417

No files matched your search

View File
Whitespace-only changes.
+188
View File
@@ -0,0 +1,188 @@
"""Claude API integration: query extraction, response shortening."""
from __future__ import annotations
import logging
import random
import time
import anthropic
from orac.constants import (
AT_MENTIONS,
MAX_RESPONSE_CHARS,
SYSTEM_PROMPT,
TRIGGERS,
)
log = logging.getLogger("orac")
# ── Anthropic client singleton ───────────────────────────────────
_client: anthropic.Anthropic | None = None
def _get_client() -> anthropic.Anthropic:
global _client
if _client is None:
_client = anthropic.Anthropic()
return _client
# ── Response shortening ──────────────────────────────────────────
def _shorten_with_claude(text: str, max_chars: int) -> str:
"""Use Claude to shorten text to fit within max_chars."""
try:
resp = _get_client().messages.create(
model="claude-sonnet-4-6",
max_tokens=256,
messages=[
{
"role": "user",
"content": f"Shorten this to {max_chars} characters: {text}",
}
],
)
shortened: str = resp.content[0].text.strip()
except Exception as e:
log.error("Shorten API error: %s", e)
shortened = text
encoded = shortened.encode("utf-8")
if len(encoded) > max_chars:
while len(shortened.encode("utf-8")) > max_chars - 1:
shortened = shortened[:-1]
shortened += "\u2026"
return shortened
def _extract_text(resp: anthropic.types.Message) -> str:
"""Extract the last non-empty text block from a Claude response."""
text = ""
for block in resp.content:
if block.type == "text" and block.text.strip():
text = block.text.strip()
return text
# ── Main query function ─────────────────────────────────────────
def call_claude(
query: str,
sender: str,
history: list[str],
max_chars: int,
context_label: str,
) -> str | None:
"""Send a query to Claude with conversation context. Returns response text or None."""
try:
nonce = random.randint(1000, 9999)
now = time.strftime("%Y-%m-%d %H:%M %Z")
prompt = (
SYSTEM_PROMPT.replace("{max_chars}", str(max_chars)) + f"\n\nCurrent date/time: {now}"
)
if history:
context = "\n".join(history)
user_content = (
f"[seed:{nonce}]\n"
f"Recent messages in {context_label}:\n{context}\n\n"
f"{sender} says: {query}"
)
else:
user_content = f"[seed:{nonce}] {sender} says: {query}"
messages: list[dict[str, object]] = [{"role": "user", "content": user_content}]
tools: list[dict[str, str]] = [{"type": "web_search_20260209", "name": "web_search"}]
text = ""
for round_num in range(3):
resp = _get_client().messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
temperature=1.0,
system=prompt,
tools=tools,
messages=messages,
)
text = _extract_text(resp)
if text:
break
if resp.stop_reason != "tool_use":
block_types = [b.type for b in resp.content]
log.warning("No text, stop=%s, blocks=%s", resp.stop_reason, block_types)
break
log.info("(web search round %d)...", round_num + 1)
messages.append({"role": "assistant", "content": resp.content})
tool_results: list[dict[str, str]] = []
for block in resp.content:
if hasattr(block, "id") and block.type == "server_tool_use":
tool_results.append(
{"type": "tool_result", "tool_use_id": block.id, "content": ""}
)
if tool_results:
messages.append({"role": "user", "content": tool_results})
else:
break
if not text:
log.warning("No text after tool loop")
return None
except Exception as e:
log.error("Claude API error: %s", e)
return None
if len(text.encode("utf-8")) > max_chars:
log.warning("Response too long (%dB), shortening...", len(text.encode("utf-8")))
text = _shorten_with_claude(text, max_chars)
return text
# ── Rate-limit message ───────────────────────────────────────────
def rate_limit_message() -> str:
"""Generate a witty rate-limit message via Claude."""
try:
nonce = random.randint(1000, 9999)
resp = _get_client().messages.create(
model="claude-sonnet-4-6",
max_tokens=100,
temperature=1.0,
system=(
f"You are Orac, a sardonic AI on a radio mesh network. "
f"Generate a single short message ({MAX_RESPONSE_CHARS} chars max, ASCII only) "
f"telling someone to slow down and try again in a few seconds. "
f"Be witty, varied, and in-character. No quotes around the message."
),
messages=[{"role": "user", "content": f"[seed:{nonce}] Rate limit hit."}],
)
text: str = resp.content[0].text.strip().strip("\"'")
if len(text.encode("utf-8")) <= MAX_RESPONSE_CHARS and text:
return text
except Exception:
pass
return "Patience. Try again in a moment."
# ── Trigger detection ────────────────────────────────────────────
def extract_trigger_query(text: str) -> str | None:
"""Check for !triggers and @mentions. Returns query text or None."""
lower = text.lower()
for trigger in (*TRIGGERS, *AT_MENTIONS):
idx = lower.find(trigger)
if idx != -1:
before = text[:idx]
after = text[idx + len(trigger) :]
query = (before + " " + after).strip()
if query:
return query
return None
+478
View File
@@ -0,0 +1,478 @@
"""Main bot loop, packet dispatch, ADVERT scheduling, connection management, CLI."""
from __future__ import annotations
import logging
import os
import sys
import time
from typing import Any, ClassVar
import serial
import donglora as dl
from orac.ai import call_claude, extract_trigger_query, rate_limit_message
from orac.constants import (
ADVERT_INTERVAL,
BOT_NAME,
MAX_DM_TEXT,
MAX_RESPONSE_CHARS,
RADIO_CONFIG,
TRIGGERS,
)
from orac.crypto import (
channel_count,
init_channels,
init_identity,
my_hash,
pubkey_bytes,
)
from orac.meshcore import (
build_ack_packet,
build_advert_packet,
build_dm_packet,
build_grp_channel_packet,
build_login_response_packet,
build_path_return_packet,
compute_ack_hash,
get_route,
has_pending_dms,
is_dm_duplicate,
is_duplicate,
is_pending_expired,
learn_route,
lookup_node_by_hash,
node_name,
parse_header_and_path,
pop_pending_dms,
queue_pending_dm,
register_node,
route_name,
try_decode_advert,
try_decrypt_anon_req,
try_decrypt_dm,
try_decrypt_grp,
)
from orac.state import (
get_channel_history,
get_dm_history,
known_node_count,
load_state,
record_channel_msg,
record_dm_msg,
save_state,
)
log = logging.getLogger("orac")
# ── Rate limiting (bot-level timers) ─────────────────────────────
_last_response: dict[str, float] = {}
_last_global_response: float = 0.0
_last_rate_limit_reply: dict[str, float] = {}
def _rate_limit_check(key: str) -> bool:
"""Check if we're allowed to respond. Returns True if OK."""
global _last_global_response
from orac.constants import RATE_LIMIT_DM, RATE_LIMIT_GLOBAL, RATE_LIMIT_PER_SENDER
now = time.monotonic()
if now - _last_global_response < RATE_LIMIT_GLOBAL:
return False
limit = RATE_LIMIT_DM if key.startswith("dm:") else RATE_LIMIT_PER_SENDER
return not (key in _last_response and now - _last_response[key] < limit)
def _rate_limit_reply_ok(key: str) -> bool:
"""Check if we should send a rate-limit reply (not too often)."""
from orac.constants import RATE_LIMIT_REPLY_COOLDOWN
now = time.monotonic()
if (
key in _last_rate_limit_reply
and now - _last_rate_limit_reply[key] < RATE_LIMIT_REPLY_COOLDOWN
):
return False
_last_rate_limit_reply[key] = now
return True
def _rate_limit_record(key: str) -> None:
"""Record that we just responded."""
global _last_global_response
now = time.monotonic()
_last_response[key] = now
_last_global_response = now
# ── Transmit helpers ─────────────────────────────────────────────
def _transmit_packet(conn: Any, packet: bytes, label: str = "Transmit") -> None: # noqa: ARG001
"""Transmit a raw MeshCore packet via the donglora library."""
try:
resp = dl.send(conn, "Transmit", payload=packet)
if resp["type"] == "Timeout":
log.error("TX failed: no response from radio")
elif resp["type"] == "Error":
log.error("TX failed: %s", resp)
except Exception as e:
log.error("TX exception: %s", e)
def _send_advert(conn: Any) -> None:
"""Transmit our ADVERT."""
packet = build_advert_packet()
log.info("Sending ADVERT as %s (hash=0x%02x)", BOT_NAME, my_hash())
_transmit_packet(conn, packet, label="ADVERT")
def _grp_transmit(conn: Any, channel_name: str, sender: str, text: str) -> None:
"""Encrypt and transmit a GRP_TXT on a channel."""
packet = build_grp_channel_packet(channel_name, sender, text)
if packet is None:
log.error("TX failed: unknown channel %s", channel_name)
return
_transmit_packet(conn, packet)
def _dm_transmit(conn: Any, peer_pubkey: bytes, text: str) -> None:
"""Encrypt and transmit a DM with simulated typing delay."""
delay = min(max(len(text) * 0.04, 1.0), 5.0)
time.sleep(delay)
packet = build_dm_packet(peer_pubkey, text)
peer = node_name(peer_pubkey.hex())
route_str = "direct" if get_route(peer_pubkey[0]) else "flood"
_transmit_packet(conn, packet, label=f"DM({route_str})->{peer}")
def _send_ack(conn: Any, ack_crc: bytes, dest_hash: int) -> None:
"""Send an ACK with a brief delay for the DM flood to clear."""
time.sleep(0.5)
packet = build_ack_packet(ack_crc, dest_hash)
_transmit_packet(conn, packet, label="ACK")
def _send_path_return(
conn: Any, peer_pubkey: bytes, path_hops: list[bytes], hash_size: int
) -> None:
"""Send a PATH return so the sender can learn a direct route to us."""
packet = build_path_return_packet(peer_pubkey, path_hops, hash_size)
_transmit_packet(conn, packet, label="PATH")
def _send_login_response(conn: Any, peer_pubkey: bytes) -> None:
"""Send a login OK response."""
packet = build_login_response_packet(peer_pubkey)
_transmit_packet(conn, packet, label=f"LOGIN_RESP->{node_name(peer_pubkey.hex())}")
# ── Pending DM processing ───────────────────────────────────────
def _process_pending_dms(conn: Any, node_hash: int, peer_label: str) -> None:
"""Process queued DMs that were waiting for an ADVERT or ANON_REQ."""
pending = pop_pending_dms(node_hash)
if not pending:
return
log.info("Processing %d pending DM(s) from %s", len(pending), peer_label)
for pending_payload, ts in pending:
if is_pending_expired(ts):
continue
dm_result = try_decrypt_dm(pending_payload)
if dm_result is None:
continue
peer_pubkey, peer_name, dm_text, dm_plaintext = dm_result
pk_hex = peer_pubkey.hex()
_send_ack(conn, compute_ack_hash(dm_plaintext, peer_pubkey), peer_pubkey[0])
if is_dm_duplicate(pk_hex, dm_text):
continue
log.info("DM(flood) from %s: %s", peer_name, dm_text)
record_dm_msg(pk_hex, f"{peer_name}: {dm_text}")
history = get_dm_history(pk_hex)
response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}")
if response:
rt = "direct" if get_route(peer_pubkey[0]) else "flood"
log.info("<<< DM(%s) %s -> %s: %s", rt, BOT_NAME, peer_name, response)
_dm_transmit(conn, peer_pubkey, response)
record_dm_msg(pk_hex, f"{BOT_NAME}: {response}")
_rate_limit_record(f"dm:{pk_hex}")
# ── Packet dispatch ──────────────────────────────────────────────
def _handle_advert(conn: Any, raw_payload: bytes) -> None:
"""Handle an incoming ADVERT packet."""
result = try_decode_advert(raw_payload)
if result is None:
return
adv_pubkey, name = result
if adv_pubkey == pubkey_bytes():
return # ignore our own ADVERT echo
register_node(adv_pubkey, name)
log.info("ADVERT %s (hash=0x%02x pk=%s...)", name, adv_pubkey[0], adv_pubkey.hex()[:16])
if has_pending_dms(adv_pubkey[0]):
_process_pending_dms(conn, adv_pubkey[0], name)
def _handle_grp_txt(conn: Any, raw_payload: bytes) -> None:
"""Handle an incoming GRP_TXT packet."""
result = try_decrypt_grp(raw_payload)
if result is None:
return
channel_name, text = result
log.info("GRP_TXT %s %s", channel_name, text)
record_channel_msg(channel_name, text)
if ": " not in text:
return
sender, _, body = text.partition(": ")
if sender == BOT_NAME:
return
query = extract_trigger_query(body)
if query is None:
return
log.info(">>> Query from %s on %s: %s", sender, channel_name, query)
rl_key = f"ch:{channel_name}:{sender}"
if not _rate_limit_check(rl_key):
log.warning("Rate limited: %s on %s", sender, channel_name)
if _rate_limit_reply_ok(f"ch:{channel_name}"):
msg = rate_limit_message()
log.info("<<< %s: %s", BOT_NAME, msg)
_grp_transmit(conn, channel_name, BOT_NAME, msg)
_rate_limit_record(rl_key)
return
history = get_channel_history(channel_name)
response = call_claude(query, sender, history, MAX_RESPONSE_CHARS, f"channel {channel_name}")
if response is None:
return
log.info("<<< %s: %s", BOT_NAME, response)
_grp_transmit(conn, channel_name, BOT_NAME, response)
record_channel_msg(channel_name, f"{BOT_NAME}: {response}")
_rate_limit_record(rl_key)
def _handle_anon_req(
conn: Any,
raw_payload: bytes,
path_hops: list[bytes],
hash_size: int,
) -> None:
"""Handle an incoming ANON_REQ (login) packet."""
result = try_decrypt_anon_req(raw_payload)
if result is None:
return
peer_pubkey, peer_name, _plaintext = result
learn_route(peer_pubkey[0], path_hops, hash_size)
log.info("LOGIN from %s -- sending login OK", peer_name)
_send_login_response(conn, peer_pubkey)
if has_pending_dms(peer_pubkey[0]):
_process_pending_dms(conn, peer_pubkey[0], peer_name)
def _handle_txt_msg(
conn: Any,
raw_payload: bytes,
route_type: int,
path_hops: list[bytes],
hash_size: int,
) -> None:
"""Handle an incoming TXT_MSG (DM) packet."""
result = try_decrypt_dm(raw_payload)
if result is None:
# If addressed to us but sender unknown, queue for later
if len(raw_payload) >= 4 and raw_payload[0] == my_hash():
src_hash = raw_payload[1]
if not lookup_node_by_hash(src_hash):
is_first = queue_pending_dm(src_hash, raw_payload)
if is_first:
log.info("Queued DM from unknown 0x%02x, waiting for their ADVERT", src_hash)
return
peer_pubkey, peer_name, dm_text, raw_plaintext = result
# Send ACK immediately so the sender stops retrying
ack_crc = compute_ack_hash(raw_plaintext, peer_pubkey)
_send_ack(conn, ack_crc, peer_pubkey[0])
# Send PATH return so sender can learn a direct route to us
_send_path_return(conn, peer_pubkey, path_hops, hash_size)
pk_hex = peer_pubkey.hex()
if is_dm_duplicate(pk_hex, dm_text):
return
log.info("DM(%s) from %s: %s", route_name(route_type), peer_name, dm_text)
record_dm_msg(pk_hex, f"{peer_name}: {dm_text}")
rl_key = f"dm:{pk_hex}"
if not _rate_limit_check(rl_key):
log.warning("Rate limited DM: %s", peer_name)
return
history = get_dm_history(pk_hex)
response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}")
if response is None:
return
rt = "direct" if get_route(peer_pubkey[0]) else "flood"
log.info("<<< DM(%s) %s -> %s: %s", rt, BOT_NAME, peer_name, response)
_dm_transmit(conn, peer_pubkey, response)
record_dm_msg(pk_hex, f"{BOT_NAME}: {response}")
_rate_limit_record(rl_key)
# ── Bot loop ─────────────────────────────────────────────────────
def bot_loop(conn: Any) -> None:
"""Main receive loop: read packets and dispatch."""
dl.send(conn, "Ping")
dl.send(conn, "SetConfig", config=RADIO_CONFIG)
dl.send(conn, "StartRx")
_send_advert(conn)
last_advert = time.monotonic()
triggers_str = ", ".join(TRIGGERS)
log.info("%s listening (Ctrl+C to stop)", BOT_NAME)
log.info("Channels: %d | Triggers: %s | DMs: enabled", channel_count(), triggers_str)
conn.timeout = 1
while True:
# Periodic ADVERT
now = time.monotonic()
if now - last_advert >= ADVERT_INTERVAL:
_send_advert(conn)
last_advert = now
pkt = dl.recv(conn)
if pkt is None:
continue
packet: bytes = pkt["payload"]
parsed = parse_header_and_path(packet)
if parsed is None:
continue
payload_type, route_type, _payload_ver, _pos, raw_payload, path_hops, hash_size = parsed
# Learn return route from incoming packet's path
if raw_payload and len(raw_payload) >= 2:
src_hash: int | None = None
if payload_type in (0x01, 0x02, 0x08): # RESPONSE, TXT_MSG, PATH
src_hash = raw_payload[1]
elif payload_type == 0x04: # ADVERT
src_hash = raw_payload[0]
if src_hash is not None:
learn_route(src_hash, path_hops, hash_size)
# Dedup via packet hash (spec Section 16)
if is_duplicate(payload_type, raw_payload):
continue
# Dispatch by payload type
if payload_type == 0x03: # ACK -- ignore (fire-and-forget)
continue
elif payload_type == 0x04: # ADVERT
_handle_advert(conn, raw_payload)
elif payload_type == 0x05: # GRP_TXT
_handle_grp_txt(conn, raw_payload)
elif payload_type == 0x07: # ANON_REQ (login)
_handle_anon_req(conn, raw_payload, path_hops, hash_size)
elif payload_type == 0x02: # TXT_MSG (DM)
_handle_txt_msg(conn, raw_payload, route_type, path_hops, hash_size)
# ── Logging setup ────────────────────────────────────────────────
class _ColorFormatter(logging.Formatter):
"""Log formatter that adds ANSI colors when stderr is a TTY."""
_COLORS: ClassVar[dict[int, str]] = {
logging.DEBUG: "\033[2m", # dim
logging.INFO: "\033[32m", # green
logging.WARNING: "\033[33m", # yellow
logging.ERROR: "\033[31m", # red
logging.CRITICAL: "\033[1;31m", # bold red
}
_RESET = "\033[0m"
def __init__(self, use_color: bool = True) -> None:
super().__init__(fmt=" %(message)s")
self._use_color = use_color
def format(self, record: logging.LogRecord) -> str:
msg = super().format(record)
if self._use_color:
color = self._COLORS.get(record.levelno, "")
return f"{color}{msg}{self._RESET}"
return msg
def _setup_logging() -> None:
"""Configure the orac logger with colored console output."""
logger = logging.getLogger("orac")
logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(_ColorFormatter(use_color=sys.stderr.isatty()))
logger.addHandler(handler)
# ── Entry point ──────────────────────────────────────────────────
def main() -> None:
"""CLI entry point."""
_setup_logging()
if not os.environ.get("ANTHROPIC_API_KEY"):
log.error("ANTHROPIC_API_KEY environment variable not set")
sys.exit(1)
init_identity()
init_channels()
load_state()
log.info("%s -- MeshCore AI Bot", BOT_NAME)
log.info(
"Max GRP response: %d chars | Max DM response: %d chars", MAX_RESPONSE_CHARS, MAX_DM_TEXT
)
log.info("Channels: %d | Known nodes: %d", channel_count(), known_node_count())
port = sys.argv[1] if len(sys.argv) > 1 else None
while True:
try:
log.info("Connecting...")
conn = dl.connect(port=port, timeout=2)
log.info("Connected")
bot_loop(conn)
except (serial.SerialException, ConnectionError, OSError) as e:
log.error("Disconnected: %s", e)
log.info("Reconnecting when device reappears...")
time.sleep(1)
except KeyboardInterrupt:
print()
save_state()
try:
conn.timeout = 2 # type: ignore[possibly-undefined]
dl.send(conn, "StopRx") # type: ignore[possibly-undefined]
except Exception:
pass
break
+96
View File
@@ -0,0 +1,96 @@
"""Orac bot constants: names, radio config, triggers, limits, paths."""
from __future__ import annotations
from pathlib import Path
# ── Bot identity ─────────────────────────────────────────────────
BOT_NAME: str = "Orac"
# ── Payload limits ───────────────────────────────────────────────
MAX_GRP_TEXT: int = 163 # max bytes for "SenderName: message" in a GRP_TXT
MAX_RESPONSE_CHARS: int = MAX_GRP_TEXT - len(BOT_NAME) - 2 # subtract "Orac: "
# DM plaintext: timestamp(4) + txt_type_attempt(1) + text + padding to 16
# Max payload 184 - dst(1) - src(1) - mac(2) = 180 bytes ciphertext
# 180 bytes ciphertext / 16 = 11 blocks -> 176 bytes plaintext max
# 176 - 5 (header) = 171 bytes text, minus null terminator padding
MAX_DM_TEXT: int = 170
# ── Trigger words ────────────────────────────────────────────────
TRIGGERS: tuple[str, ...] = ("!askai", "!ai", "!claude", "!orac")
AT_MENTIONS: tuple[str, ...] = (f"@{BOT_NAME}".lower(), f"@[{BOT_NAME}]".lower())
# ── History / rate-limit sizing ──────────────────────────────────
CHANNEL_HISTORY_SIZE: int = 20
DM_HISTORY_SIZE: int = 20
RATE_LIMIT_PER_SENDER: float = 5 # seconds, channel messages
RATE_LIMIT_GLOBAL: float = 2 # seconds, any two responses
RATE_LIMIT_DM: float = 2 # seconds, DMs (more interactive)
RATE_LIMIT_REPLY_COOLDOWN: float = 30 # seconds between rate-limit replies
# ── Timers ───────────────────────────────────────────────────────
ADVERT_INTERVAL: float = 7200 # 2 hours in seconds
# ── Paths ────────────────────────────────────────────────────────
DATA_DIR: Path = Path.home() / ".donglora"
KEY_FILE: Path = DATA_DIR / "orac_key.bin"
STATE_FILE: Path = DATA_DIR / "orac_state.json"
# ── Radio ────────────────────────────────────────────────────────
RADIO_CONFIG: dict[str, int] = {
"freq_hz": 910_525_000,
"bw": 6, # 62.5 kHz
"sf": 7,
"cr": 5, # CR 4/5
"sync_word": 0x3444,
"tx_power_dbm": -128, # TX_POWER_MAX
}
# ── AI system prompt ─────────────────────────────────────────────
SYSTEM_PROMPT: str = """\
You are Orac, a terse AI assistant on a MeshCore LoRa radio mesh network.
ABSOLUTE HARD LIMIT: Your ENTIRE response must be {{max_chars}} characters or fewer.
This is a physical constraint of the radio protocol. Every character beyond this limit
is permanently lost and never transmitted. There is NO exception.
Stay well under {{max_chars}}. Aim for 120 or fewer. NEVER show a character count or HTML tags in your reply.
You can see recent messages for context. When someone asks you a question, they may
be referring to the ongoing conversation — use the chat history to understand what they mean.
For example, "what do you think?" refers to whatever was just discussed. "tell me more" means
elaborate on the recent topic. Read the room.
Rules:
- One sentence or short phrase. Never more.
- Emoji OK but don't overdo it. No markdown, no bullet points, no formatting.
- Use common abbreviations freely (e.g., w/, b/c, approx, etc).
- Skip all pleasantries, greetings, and filler.
- You have a dry, sardonic wit. Be helpful but never wordy.
- If the question requires a long answer, give the most important point only.
- NEVER repeat a previous answer. Always find a fresh angle, new phrasing, or different take.
- Be creative and unpredictable. Surprise the reader.\
"""
# ── MeshCore protocol constants ──────────────────────────────────
ADVERT_MAX_AGE: float = 43200 # 12 hours (display only, not key expiry)
PENDING_DM_TTL: float = 300 # 5 minutes
DEDUP_TTL: float = 120 # seconds
DM_DEDUP_TTL: float = 60 # seconds
RESP_SERVER_LOGIN_OK: int = 0x01
# ── Channels CSV path ────────────────────────────────────────────
# Resolved at import time relative to the project root (ai_bot.py's directory).
CHANNELS_CSV: Path = Path(__file__).resolve().parent.parent / "channels.csv"
+326
View File
@@ -0,0 +1,326 @@
"""Identity (Ed25519 keypair), channel crypto, peer crypto, ECDH."""
from __future__ import annotations
import csv
import hashlib
import hmac
import logging
import struct
import time
from Crypto.Cipher import AES
from nacl.bindings import crypto_scalarmult
from nacl.signing import SigningKey, VerifyKey
from orac.constants import (
BOT_NAME,
CHANNELS_CSV,
DATA_DIR,
KEY_FILE,
MAX_GRP_TEXT,
)
log = logging.getLogger("orac")
# ── Identity ─────────────────────────────────────────────────────
_signing_key: SigningKey | None = None
_verify_key: VerifyKey | None = None
_pubkey_bytes: bytes = b""
def init_identity() -> None:
"""Load or generate Ed25519 keypair. Persisted to disk."""
global _signing_key, _verify_key, _pubkey_bytes
DATA_DIR.mkdir(parents=True, exist_ok=True)
if KEY_FILE.is_file():
seed = KEY_FILE.read_bytes()
if len(seed) == 32:
_signing_key = SigningKey(seed)
log.info("Loaded keypair from %s", KEY_FILE)
else:
log.warning("Invalid key file, regenerating")
_signing_key = SigningKey.generate()
KEY_FILE.write_bytes(bytes(_signing_key))
else:
_signing_key = SigningKey.generate()
KEY_FILE.write_bytes(bytes(_signing_key))
log.info("Generated new keypair -> %s", KEY_FILE)
_verify_key = _signing_key.verify_key
_pubkey_bytes = bytes(_verify_key)
log.info("Pubkey: %s", _pubkey_bytes.hex())
log.info("Node hash: 0x%02x", _pubkey_bytes[0])
def my_hash() -> int:
"""Our 1-byte node hash (first byte of pubkey)."""
return _pubkey_bytes[0]
def pubkey_bytes() -> bytes:
"""Our Ed25519 public key (32 bytes)."""
return _pubkey_bytes
def sign(message: bytes) -> bytes:
"""Sign *message* with our identity key. Returns the 64-byte signature."""
assert _signing_key is not None
signed = _signing_key.sign(message)
return signed.signature
# ── Channel crypto ───────────────────────────────────────────────
def channel_secret_from_hashtag(name: str) -> bytes:
"""Derive a 32-byte channel secret from a hashtag name."""
h = hashlib.sha256(name.encode()).digest()
return h[:16] + b"\x00" * 16
def channel_hash(secret: bytes) -> int:
"""Compute the 1-byte channel hash from a secret."""
key_len = 16 if secret[16:] == b"\x00" * 16 else 32
return hashlib.sha256(secret[:key_len]).digest()[0]
def grp_verify_and_decrypt(secret: bytes, mac_bytes: bytes, ciphertext: bytes) -> bytes | None:
"""Verify MAC and decrypt a group message. Returns plaintext or None."""
key_len = 32 # HMAC always uses full 32-byte secret (spec Section 14)
computed = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2]
if computed != mac_bytes:
return None
cipher = AES.new(secret[:16], AES.MODE_ECB)
plaintext = b""
for i in range(0, len(ciphertext), 16):
plaintext += cipher.decrypt(ciphertext[i : i + 16])
return plaintext
def parse_grp_plaintext(plaintext: bytes) -> tuple[int, str] | None:
"""Parse decrypted group plaintext. Returns (timestamp, text) or None."""
if len(plaintext) < 5:
return None
timestamp: int = struct.unpack_from("<I", plaintext, 0)[0]
text = plaintext[5:].split(b"\x00", 1)[0]
return timestamp, text.decode("utf-8", errors="replace")
def grp_encrypt(secret: bytes, sender: str, text: str) -> bytes:
"""Encrypt a GRP_TXT payload. Returns channel_hash + mac + ciphertext."""
ch = channel_hash(secret)
plaintext = struct.pack("<I", int(time.time())) + b"\x00"
plaintext += f"{sender}: {text}\x00".encode()[:MAX_GRP_TEXT]
pad_len = (16 - len(plaintext) % 16) % 16
plaintext += b"\x00" * pad_len
cipher = AES.new(secret[:16], AES.MODE_ECB)
ciphertext = b""
for i in range(0, len(plaintext), 16):
ciphertext += cipher.encrypt(plaintext[i : i + 16])
key_len = 32 # HMAC always uses full 32-byte secret (spec Section 14)
mac = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2]
return bytes([ch]) + mac + ciphertext
# ── Peer-to-peer (DM) crypto ────────────────────────────────────
def ecdh_shared_secret(peer_ed25519_pub: bytes) -> bytes:
"""Compute X25519 shared secret from our Ed25519 key and peer's Ed25519 pubkey."""
assert _signing_key is not None
my_x25519 = bytes(_signing_key.to_curve25519_private_key())
peer_verify = VerifyKey(peer_ed25519_pub)
peer_x25519 = bytes(peer_verify.to_curve25519_public_key())
return crypto_scalarmult(my_x25519, peer_x25519)
def peer_encrypt(shared_secret: bytes, text: str) -> bytes:
"""Encrypt a DM plaintext. Returns mac(2) + ciphertext."""
plaintext = struct.pack("<I", int(time.time())) + b"\x00"
plaintext += text.encode("utf-8") + b"\x00"
pad_len = (16 - len(plaintext) % 16) % 16
plaintext += b"\x00" * pad_len
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
ciphertext = b""
for i in range(0, len(plaintext), 16):
ciphertext += cipher.encrypt(plaintext[i : i + 16])
mac = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2]
return mac + ciphertext
def peer_verify_and_decrypt(
shared_secret: bytes, mac_bytes: bytes, ciphertext: bytes
) -> bytes | None:
"""Verify MAC then decrypt a peer message. Returns plaintext or None."""
computed = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2]
if computed != mac_bytes:
return None
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
plaintext = b""
for i in range(0, len(ciphertext), 16):
plaintext += cipher.decrypt(ciphertext[i : i + 16])
return plaintext
def parse_peer_plaintext(plaintext: bytes) -> str | None:
"""Parse decrypted peer message: timestamp(4) + txt_type_attempt(1) + text."""
if len(plaintext) < 5:
return None
text = plaintext[5:].split(b"\x00", 1)[0]
return text.decode("utf-8", errors="replace") if text else None
def verify_advert_signature(
pubkey: bytes, timestamp: bytes, app_data: bytes, signature: bytes
) -> bool:
"""Verify an ADVERT's Ed25519 signature. Returns True on success."""
try:
verify_key = VerifyKey(pubkey)
sign_msg = pubkey + timestamp + app_data
verify_key.verify(sign_msg, signature)
return True
except Exception:
return False
# ── Channel registry ─────────────────────────────────────────────
_channels: dict[str, bytes] = {}
_channel_by_hash: dict[int, list[tuple[str, bytes]]] = {}
def register_channel(name: str, secret: bytes) -> None:
"""Register a channel name and its secret."""
_channels[name] = secret
h = channel_hash(secret)
_channel_by_hash.setdefault(h, []).append((name, secret))
def get_channel_secret(name: str) -> bytes | None:
"""Get the secret for a registered channel, or None."""
return _channels.get(name)
def get_channels_by_hash(h: int) -> list[tuple[str, bytes]]:
"""Get all channel (name, secret) pairs matching a hash byte."""
return _channel_by_hash.get(h, [])
def channel_count() -> int:
"""Number of registered channels."""
return len(_channels)
_PUBLIC_PSK_HEX: str = "8b3387e9c5cdea6ac9e5edbaa115cd72"
"""MeshCore Public channel pre-shared key (fixed, not hashtag-derived)."""
_BUILTIN_HASHTAG_CHANNELS: tuple[str, ...] = (
# --- AI / bot trigger channels ---
"#askai",
"#ai",
"#aibot",
"#aibots",
"#orac",
"#bot",
# --- Operations ---
"#nodeops",
"#repeater-ops",
"#net",
"#emcomm",
# --- General purpose ---
"#general",
"#test",
"#weather",
"#protest",
# --- Dev / testing ---
"#devtest",
"#devtestdevtest",
)
"""Built-in hashtag channels (key derived from SHA-256 of the name)."""
def init_channels() -> None:
"""Register the MeshCore Public channel, built-in hashtag channels, and any CSV overrides."""
# Public channel — fixed PSK, not hashtag-derived
register_channel("Public", bytes.fromhex(_PUBLIC_PSK_HEX) + b"\x00" * 16)
# Built-in hashtag channels
for name in _BUILTIN_HASHTAG_CHANNELS:
register_channel(name, channel_secret_from_hashtag(name))
# Optional CSV for additional channels (operator-defined)
if CHANNELS_CSV.is_file():
with open(CHANNELS_CSV, newline="") as f:
for row in csv.DictReader(f):
name: str = row["channel_name"]
is_hashtag = row["hashtag"].strip().lower() == "true"
key_hex: str = row["key_hex"].strip()
if is_hashtag:
secret = channel_secret_from_hashtag(name)
else:
secret = bytes.fromhex(key_hex) + b"\x00" * 16
register_channel(name, secret)
def aes_ecb_encrypt(key: bytes, plaintext: bytes) -> bytes:
"""AES-ECB encrypt (16-byte aligned plaintext). Used for PATH and RESPONSE."""
cipher = AES.new(key[:16], AES.MODE_ECB)
ciphertext = b""
for i in range(0, len(plaintext), 16):
ciphertext += cipher.encrypt(plaintext[i : i + 16])
return ciphertext
def compute_mac(secret: bytes, ciphertext: bytes) -> bytes:
"""Compute 2-byte HMAC-SHA256 MAC over ciphertext."""
return hmac.new(secret[:32], ciphertext, hashlib.sha256).digest()[:2]
def build_advert_payload() -> bytes:
"""Build the inner ADVERT payload: pubkey + timestamp + signature + app_data."""
timestamp = struct.pack("<I", int(time.time()))
app_data = bytes([0x81]) + BOT_NAME.encode("utf-8")
sign_msg = _pubkey_bytes + timestamp + app_data
signature = sign(sign_msg)
return _pubkey_bytes + timestamp + signature + app_data
def raw_peer_encrypt(shared_secret: bytes, plaintext: bytes) -> bytes:
"""Encrypt raw plaintext (not text message) for a peer. Returns mac(2) + ciphertext."""
pad_len = (16 - len(plaintext) % 16) % 16
padded = plaintext + b"\x00" * pad_len
ciphertext = aes_ecb_encrypt(shared_secret[:16], padded)
mac = compute_mac(shared_secret, ciphertext)
return mac + ciphertext
# Silence unused-import linter for `Any` — used only in type annotations
__all__: list[str] = [
"aes_ecb_encrypt",
"build_advert_payload",
"channel_count",
"channel_hash",
"channel_secret_from_hashtag",
"compute_mac",
"ecdh_shared_secret",
"get_channel_secret",
"get_channels_by_hash",
"grp_encrypt",
"grp_verify_and_decrypt",
"init_channels",
"init_identity",
"my_hash",
"parse_grp_plaintext",
"parse_peer_plaintext",
"peer_encrypt",
"peer_verify_and_decrypt",
"pubkey_bytes",
"raw_peer_encrypt",
"register_channel",
"sign",
"verify_advert_signature",
]
+453
View File
@@ -0,0 +1,453 @@
"""MeshCore packet parsing/building, routing, dedup, node registry, ADVERT, ACK, PATH."""
from __future__ import annotations
import hashlib
import logging
import os
import struct
import time
from orac.constants import (
DEDUP_TTL,
DM_DEDUP_TTL,
PENDING_DM_TTL,
RESP_SERVER_LOGIN_OK,
)
from orac.crypto import (
build_advert_payload,
ecdh_shared_secret,
grp_encrypt,
my_hash,
peer_encrypt,
raw_peer_encrypt,
verify_advert_signature,
)
log = logging.getLogger("orac")
# ── Route table ──────────────────────────────────────────────────
# src_hash (int) -> (reversed_path_hops: list[bytes], hash_size: int)
_route_table: dict[int, tuple[list[bytes], int]] = {}
_RT_NAMES: dict[int, str] = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"}
def learn_route(src_hash: int, path_hops: list[bytes], hash_size: int) -> None:
"""Learn a return route from an incoming packet's path.
path_hops is ordered closest-relay-first as received.
We reverse it so our outgoing path goes through the same relays
back toward the sender. Prefers larger hash sizes (3 > 2 > 1).
"""
existing = _route_table.get(src_hash)
if existing is not None:
_, existing_hs = existing
if hash_size < existing_hs:
return # Don't downgrade hash size
_route_table[src_hash] = (list(reversed(path_hops)), hash_size)
def get_route(dest_hash: int) -> tuple[list[bytes], int] | None:
"""Get a learned return route for a destination, or None if unknown."""
return _route_table.get(dest_hash)
def route_name(route_type: int) -> str:
"""Human-readable name for a route type."""
return _RT_NAMES.get(route_type, f"rt{route_type}")
# ── Node registry ────────────────────────────────────────────────
# Stored in state module; these helpers bridge to it.
from orac import state as _state_mod # noqa: E402
def register_node(pubkey: bytes, name: str) -> bool:
"""Register a node. Returns True if this is a NEW node."""
return _state_mod.register_node(pubkey, name)
def lookup_node_by_hash(hash_byte: int) -> list[tuple[bytes, str]]:
"""Find all known nodes whose pubkey first byte matches."""
return _state_mod.lookup_node_by_hash(hash_byte)
def node_name(pubkey_hex: str) -> str:
"""Human-readable name for a node, or truncated hex."""
return _state_mod.node_name(pubkey_hex)
# ── Deduplication ────────────────────────────────────────────────
_seen_packets: dict[bytes, float] = {}
_seen_dm_texts: dict[str, float] = {} # "pubkey_hex:text" -> monotonic timestamp
def is_duplicate(payload_type: int, payload: bytes) -> bool:
"""Dedup via 8-byte packet hash (spec Section 16)."""
now = time.monotonic()
if len(_seen_packets) > 500:
expired = [k for k, t in _seen_packets.items() if now - t > DEDUP_TTL]
for k in expired:
del _seen_packets[k]
pkt_hash = hashlib.sha256(bytes([payload_type]) + payload).digest()[:8]
if pkt_hash in _seen_packets and now - _seen_packets[pkt_hash] < DEDUP_TTL:
return True
_seen_packets[pkt_hash] = now
return False
def is_dm_duplicate(peer_pubkey_hex: str, text: str) -> bool:
"""Dedup on decrypted DM text (catches retries with different attempt counters)."""
now = time.monotonic()
if len(_seen_dm_texts) > 200:
expired = [k for k, t in _seen_dm_texts.items() if now - t > DM_DEDUP_TTL]
for k in expired:
del _seen_dm_texts[k]
key = f"{peer_pubkey_hex}:{text}"
if key in _seen_dm_texts and now - _seen_dm_texts[key] < DM_DEDUP_TTL:
return True
_seen_dm_texts[key] = now
return False
# ── Pending DMs from unknown senders ─────────────────────────────
_pending_dms: dict[int, list[tuple[bytes, float]]] = {}
def queue_pending_dm(src_hash: int, raw_payload: bytes) -> bool:
"""Queue a DM from an unknown sender. Returns True if this is the first queued."""
_pending_dms.setdefault(src_hash, []).append((raw_payload, time.monotonic()))
return len(_pending_dms[src_hash]) == 1
def pop_pending_dms(node_hash: int) -> list[tuple[bytes, float]]:
"""Pop and return all pending DMs for a node hash."""
return _pending_dms.pop(node_hash, [])
def has_pending_dms(node_hash: int) -> bool:
"""Check if there are pending DMs for a node hash."""
return bool(_pending_dms.get(node_hash))
def is_pending_expired(timestamp: float) -> bool:
"""Check if a pending DM timestamp is expired."""
return time.monotonic() - timestamp > PENDING_DM_TTL
# ── Packet parsing ───────────────────────────────────────────────
def parse_header_and_path(
packet: bytes,
) -> tuple[int, int, int, int, bytes, list[bytes], int] | None:
"""Parse MeshCore header + path.
Returns (payload_type, route_type, payload_ver, pos, payload, path_hops, hash_size)
or None. path_hops is a list of raw hash bytes for each hop (closest relay first).
"""
if len(packet) < 3:
return None
header = packet[0]
route_type = header & 0x03
payload_type = (header >> 2) & 0x0F
payload_ver = (header >> 6) & 0x03
if payload_ver != 0:
return None
has_tc = route_type in (0, 3)
for skip_tc in [True, False] if has_tc else [False]:
pos = 5 if skip_tc else 1
if pos >= len(packet):
continue
path_len_byte = packet[pos]
pos += 1
hash_size_code = path_len_byte >> 6
if hash_size_code == 3:
continue
hash_size = hash_size_code + 1
hop_count = path_len_byte & 0x3F
path_bytes = hop_count * hash_size
if path_bytes > 64 or pos + path_bytes > len(packet):
continue
hops: list[bytes] = []
for i in range(hop_count):
hops.append(packet[pos + i * hash_size : pos + (i + 1) * hash_size])
pos += path_bytes
payload = packet[pos:]
if payload and len(payload) <= 184:
return payload_type, route_type, payload_ver, pos, payload, hops, hash_size
return None
# ── Packet building ──────────────────────────────────────────────
def _build_routed_packet(payload_type: int, payload: bytes, dest_hash: int) -> bytes:
"""Build a packet using a learned route if available, otherwise flood."""
route = get_route(dest_hash)
if route is not None:
hops, hash_size = route
hash_size_code = hash_size - 1
path_len_byte = (hash_size_code << 6) | (len(hops) & 0x3F)
path_data = b"".join(hops)
header = bytes([(payload_type << 2) | 2]) # direct route_type = 2
return header + bytes([path_len_byte]) + path_data + payload
else:
header = bytes([(payload_type << 2) | 1]) # flood route_type = 1
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
return header + path_len + payload
def build_grp_packet(channel_payload: bytes) -> bytes:
"""Wrap channel payload in a GRP_TXT flood packet."""
header = bytes([0x15]) # GRP_TXT flood: (5 << 2) | 1
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
return header + path_len + channel_payload
def build_advert_packet() -> bytes:
"""Build a MeshCore ADVERT packet for this node."""
advert_payload = build_advert_payload()
header = bytes([0x11]) # ADVERT flood: (4 << 2) | 1
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
return header + path_len + advert_payload
def build_dm_packet(peer_pubkey: bytes, text: str) -> bytes:
"""Build a TXT_MSG DM packet using learned route or flood."""
shared_secret = ecdh_shared_secret(peer_pubkey)
mac_ct = peer_encrypt(shared_secret, text)
dm_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct
return _build_routed_packet(0x02, dm_payload, peer_pubkey[0])
def build_ack_packet(ack_crc: bytes, dest_hash: int) -> bytes:
"""Build an ACK packet using learned route or flood."""
return _build_routed_packet(0x03, ack_crc, dest_hash)
def build_path_return_packet(peer_pubkey: bytes, path_hops: list[bytes], hash_size: int) -> bytes:
"""Build a PATH return (0x08) so the sender can learn a direct route to us."""
reversed_hops = list(reversed(path_hops))
hash_size_code = hash_size - 1
path_len_byte = (hash_size_code << 6) | (len(reversed_hops) & 0x3F)
path_data = b"".join(reversed_hops)
# Inner plaintext: path_len + path + 0xFF (dummy extra) + 4 random bytes
inner = bytes([path_len_byte]) + path_data + b"\xff" + os.urandom(4)
shared_secret = ecdh_shared_secret(peer_pubkey)
mac_ct = raw_peer_encrypt(shared_secret, inner)
# Outer: dest_hash + src_hash + mac + ciphertext
path_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct
return _build_routed_packet(0x08, path_payload, peer_pubkey[0])
def build_login_response_packet(peer_pubkey: bytes) -> bytes:
"""Build a RESPONSE packet (login OK) back to the peer."""
shared_secret = ecdh_shared_secret(peer_pubkey)
# Response payload: timestamp(4) + RESP_SERVER_LOGIN_OK(1) + zero(1) + permissions(2)
reply = struct.pack("<I", int(time.time()))
reply += bytes([RESP_SERVER_LOGIN_OK, 0x00, 0x00, 0xFF])
mac_ct = raw_peer_encrypt(shared_secret, reply)
resp_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct
return _build_routed_packet(0x01, resp_payload, peer_pubkey[0])
def build_grp_channel_packet(channel_name: str, sender: str, text: str) -> bytes | None:
"""Build a complete GRP_TXT packet for a channel. Returns None if channel unknown."""
from orac.crypto import get_channel_secret
secret = get_channel_secret(channel_name)
if secret is None:
return None
payload = grp_encrypt(secret, sender, text)
return build_grp_packet(payload)
# ── ACK hash computation ────────────────────────────────────────
def compute_ack_hash(plaintext: bytes, sender_pubkey: bytes) -> bytes:
"""Compute 4-byte ACK hash per MeshCore BaseChatMesh.cpp.
Hash = SHA-256(frag1 || sender_pubkey)[0:4]
frag1 = timestamp(4) + txt_type_attempt(1) + text (no null terminator)
"""
text_bytes = plaintext[5:]
null_pos = text_bytes.find(b"\x00")
text_len = null_pos if null_pos >= 0 else len(text_bytes)
frag1 = plaintext[: 5 + text_len]
return hashlib.sha256(frag1 + sender_pubkey).digest()[:4]
# ── GRP_TXT interception ────────────────────────────────────────
def try_decrypt_grp(raw_payload: bytes) -> tuple[str, str] | None:
"""Try to decrypt a GRP_TXT payload. Returns (channel_name, text) or None."""
from orac.crypto import get_channels_by_hash, grp_verify_and_decrypt, parse_grp_plaintext
if len(raw_payload) < 19:
return None
ch = raw_payload[0]
mac_bytes = raw_payload[1:3]
ciphertext = raw_payload[3:]
candidates = get_channels_by_hash(ch)
for chan_name, secret in candidates:
plaintext = grp_verify_and_decrypt(secret, mac_bytes, ciphertext)
if plaintext is not None:
parsed = parse_grp_plaintext(plaintext)
if parsed:
_, text = parsed
return chan_name, text
return None
# ── DM interception ──────────────────────────────────────────────
def try_decrypt_dm(raw_payload: bytes) -> tuple[bytes, str, str, bytes] | None:
"""Try to decrypt a TXT_MSG DM addressed to us.
Returns (sender_pubkey, sender_name, message_text, raw_plaintext) or None.
"""
from orac.crypto import parse_peer_plaintext, peer_verify_and_decrypt
if len(raw_payload) < 20:
return None
dest_hash = raw_payload[0]
src_hash = raw_payload[1]
mac_bytes = raw_payload[2:4]
ciphertext = raw_payload[4:]
log.debug(
"TXT_MSG dest=0x%02x src=0x%02x me=0x%02x [%dB]",
dest_hash,
src_hash,
my_hash(),
len(ciphertext),
)
if dest_hash != my_hash():
return None
candidates = lookup_node_by_hash(src_hash)
if not candidates:
log.warning("DM for us but no known node with hash 0x%02x", src_hash)
return None
for peer_pubkey, peer_name in candidates:
try:
shared_secret = ecdh_shared_secret(peer_pubkey)
plaintext = peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext)
if plaintext is not None:
text = parse_peer_plaintext(plaintext)
if text:
return peer_pubkey, peer_name, text, plaintext
except Exception:
pk_hex = peer_pubkey.hex()
log.warning("Evicting bad key for %s (%s...)", peer_name, pk_hex[:16])
_state_mod.evict_node(pk_hex)
continue
return None
def try_decrypt_anon_req(
raw_payload: bytes,
) -> tuple[bytes, str, bytes | None] | None:
"""Try to decrypt an ANON_REQ (login).
Returns (sender_pubkey, sender_name, plaintext_or_None) or None.
"""
from orac.crypto import peer_verify_and_decrypt
if len(raw_payload) < 51:
return None
dest_hash = raw_payload[0]
sender_pubkey = raw_payload[1:33]
mac_bytes = raw_payload[33:35]
ciphertext = raw_payload[35:]
log.info(
"ANON_REQ dest=0x%02x sender=%s... [%dB]",
dest_hash,
sender_pubkey.hex()[:16],
len(ciphertext),
)
if dest_hash != my_hash():
return None
# Register sender -- we now have their full pubkey
peer_name = node_name(sender_pubkey.hex())
is_new = register_node(sender_pubkey, peer_name)
if is_new:
log.info(
"Registered new peer from ANON_REQ: %s (hash=0x%02x)",
peer_name,
sender_pubkey[0],
)
try:
shared_secret = ecdh_shared_secret(sender_pubkey)
plaintext = peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext)
if plaintext is not None:
return sender_pubkey, peer_name, plaintext
else:
log.warning("ANON_REQ MAC mismatch")
except Exception as e:
log.error("ANON_REQ decrypt error: %s", e)
return sender_pubkey, peer_name, None
def try_decode_advert(raw_payload: bytes) -> tuple[bytes, str] | None:
"""Decode and verify an ADVERT payload. Returns (pubkey, name) or None."""
if len(raw_payload) < 100:
return None
pubkey = raw_payload[0:32]
timestamp = raw_payload[32:36]
signature = raw_payload[36:100]
app_data = raw_payload[100:]
if not verify_advert_signature(pubkey, timestamp, app_data, signature):
return None
name = ""
if app_data:
flags = app_data[0]
pos = 1
if flags & 0x10: # has location
pos += 8
if flags & 0x20: # feat1
pos += 2
if flags & 0x40: # feat2
pos += 2
if flags & 0x80 and pos < len(app_data): # has name
name = app_data[pos:].decode("utf-8", errors="replace")
return pubkey, name if name else pubkey.hex()[:8]
+132
View File
@@ -0,0 +1,132 @@
"""Persistent state (JSON), history tracking, rate limiting."""
from __future__ import annotations
import json
import logging
import time
from orac.constants import (
CHANNEL_HISTORY_SIZE,
DATA_DIR,
DM_HISTORY_SIZE,
STATE_FILE,
)
log = logging.getLogger("orac")
# ── Persistent state ─────────────────────────────────────────────
_state: dict[str, dict[str, object]] = {
"channel_history": {}, # channel_name -> list of messages
"dm_history": {}, # peer_pubkey_hex -> list of messages
"known_nodes": {}, # pubkey_hex -> {"name": str, "seen": float}
}
def load_state() -> None:
"""Load persisted state from disk."""
global _state
if STATE_FILE.is_file():
try:
with open(STATE_FILE) as f:
loaded = json.load(f)
for key in _state:
if key in loaded:
_state[key] = loaded[key]
log.info("Loaded state from %s", STATE_FILE)
except Exception as e:
log.warning("Failed to load state: %s", e)
def save_state() -> None:
"""Persist state to disk (atomic write via tmp + rename)."""
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
tmp = STATE_FILE.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(_state, f, indent=2)
tmp.rename(STATE_FILE)
except Exception as e:
log.error("Failed to save state: %s", e)
# ── Node registry (state-backed) ────────────────────────────────
def register_node(pubkey: bytes, name: str) -> bool:
"""Register a node. Returns True if this is a NEW node (not just an update)."""
pk_hex = pubkey.hex()
is_new = pk_hex not in _state["known_nodes"]
_state["known_nodes"][pk_hex] = {"name": name, "seen": time.time()} # type: ignore[index]
save_state()
return is_new
def lookup_node_by_hash(hash_byte: int) -> list[tuple[bytes, str]]:
"""Find all known nodes whose pubkey first byte matches.
Never expires keys -- once we learn a peer's pubkey, we can always decrypt
their DMs. Peers shouldn't need to re-advertise just to message the bot.
"""
results: list[tuple[bytes, str]] = []
for pk_hex, info in _state["known_nodes"].items(): # type: ignore[union-attr]
pk_bytes = bytes.fromhex(pk_hex)
if pk_bytes[0] == hash_byte:
results.append((pk_bytes, info["name"])) # type: ignore[index]
return results
def node_name(pubkey_hex: str) -> str:
"""Human-readable name for a node, or truncated hex."""
info = _state["known_nodes"].get(pubkey_hex) # type: ignore[union-attr]
return info["name"] if info else pubkey_hex[:8] # type: ignore[index]
def evict_node(pubkey_hex: str) -> None:
"""Remove a node from the registry (e.g., bad key)."""
_state["known_nodes"].pop(pubkey_hex, None) # type: ignore[union-attr]
save_state()
def known_node_count() -> int:
"""Number of known nodes."""
return len(_state["known_nodes"])
# ── Channel history ──────────────────────────────────────────────
def record_channel_msg(channel: str, text: str) -> None:
"""Append a message to channel history and persist."""
hist = _state["channel_history"]
if channel not in hist: # type: ignore[operator]
hist[channel] = [] # type: ignore[index]
hist[channel].append(text) # type: ignore[index]
if len(hist[channel]) > CHANNEL_HISTORY_SIZE: # type: ignore[index]
hist[channel] = hist[channel][-CHANNEL_HISTORY_SIZE:] # type: ignore[index]
save_state()
def get_channel_history(channel: str) -> list[str]:
"""Get recent channel history."""
return list(_state["channel_history"].get(channel, [])) # type: ignore[union-attr]
# ── DM history ───────────────────────────────────────────────────
def record_dm_msg(peer_pubkey_hex: str, text: str) -> None:
"""Append a message to DM history and persist."""
hist = _state["dm_history"]
if peer_pubkey_hex not in hist: # type: ignore[operator]
hist[peer_pubkey_hex] = [] # type: ignore[index]
hist[peer_pubkey_hex].append(text) # type: ignore[index]
if len(hist[peer_pubkey_hex]) > DM_HISTORY_SIZE: # type: ignore[index]
hist[peer_pubkey_hex] = hist[peer_pubkey_hex][-DM_HISTORY_SIZE:] # type: ignore[index]
save_state()
def get_dm_history(peer_pubkey_hex: str) -> list[str]:
"""Get recent DM history for a peer."""
return list(_state["dm_history"].get(peer_pubkey_hex, [])) # type: ignore[union-attr]