mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-08 17:53:10 +02:00
Add historical DM decryption
This commit is contained in:
+24
-5
@@ -291,12 +291,30 @@ if result:
|
||||
|
||||
### Direct Message Decryption
|
||||
|
||||
Direct messages use ECDH key exchange (Ed25519 → X25519). Server-side decryption
|
||||
of direct messages is **not yet implemented**. Currently, direct messages are
|
||||
decrypted by the MeshCore library on the radio itself.
|
||||
Direct messages use ECDH key exchange (Ed25519 → X25519) for shared secret derivation.
|
||||
|
||||
The decoder module contains a `try_decrypt_packet_with_contact_key()` function
|
||||
that could support this feature in the future.
|
||||
**Key storage**: The private key is exported from the radio on startup and stored in memory
|
||||
via `keystore.py`. This enables server-side DM decryption even when contacts aren't loaded
|
||||
on the radio.
|
||||
|
||||
**Real-time decryption**: When a `RAW_DATA` event contains a `TEXT_MESSAGE` packet, the
|
||||
`packet_processor.py` attempts to decrypt it using known contact public keys and the
|
||||
stored private key.
|
||||
|
||||
**Historical decryption**: When creating a contact with `try_historical=True`, the server
|
||||
attempts to decrypt all stored `TEXT_MESSAGE` packets for that contact.
|
||||
|
||||
**Direction detection**: The decoder uses the 1-byte dest_hash and src_hash to determine
|
||||
if a message is incoming or outgoing. Edge case: when both bytes match (1/256 chance),
|
||||
defaults to treating as incoming.
|
||||
|
||||
```python
|
||||
from app.decoder import try_decrypt_dm
|
||||
|
||||
result = try_decrypt_dm(raw_bytes, private_key, contact_public_key)
|
||||
if result:
|
||||
print(f"{result.message} (timestamp={result.timestamp})")
|
||||
```
|
||||
|
||||
## Advertisement Parsing (`decoder.py`)
|
||||
|
||||
@@ -407,6 +425,7 @@ All endpoints are prefixed with `/api`.
|
||||
### Contacts
|
||||
- `GET /api/contacts` - List from database
|
||||
- `GET /api/contacts/{key}` - Get by public key or prefix
|
||||
- `POST /api/contacts` - Create contact (optionally trigger historical DM decryption)
|
||||
- `POST /api/contacts/sync` - Pull from radio to database
|
||||
- `POST /api/contacts/{key}/add-to-radio` - Push to radio
|
||||
- `POST /api/contacts/{key}/remove-from-radio` - Remove from radio
|
||||
|
||||
+225
@@ -9,6 +9,7 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
import nacl.bindings
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,6 +49,17 @@ class DecryptedGroupText:
|
||||
channel_hash: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptedDirectMessage:
|
||||
"""Result of decrypting a TEXT_MESSAGE (direct message)."""
|
||||
|
||||
timestamp: int
|
||||
flags: int
|
||||
message: str
|
||||
dest_hash: str # First byte of destination pubkey as hex
|
||||
src_hash: str # First byte of sender pubkey as hex
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedAdvertisement:
|
||||
"""Result of parsing an advertisement packet."""
|
||||
@@ -394,3 +406,216 @@ def try_parse_advertisement(raw_packet: bytes) -> ParsedAdvertisement | None:
|
||||
return None
|
||||
|
||||
return parse_advertisement(packet_info.payload)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Direct Message (TEXT_MESSAGE) Decryption
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _clamp_scalar(k: bytes) -> bytes:
|
||||
"""
|
||||
Clamp a 32-byte scalar for X25519.
|
||||
|
||||
This applies the standard X25519 clamping to ensure the scalar
|
||||
is in the correct form for elliptic curve operations.
|
||||
|
||||
Note: MeshCore private keys are already clamped (they store the post-SHA-512
|
||||
scalar directly rather than a seed). Clamping is idempotent, so this is safe.
|
||||
"""
|
||||
clamped = bytearray(k[:32])
|
||||
clamped[0] &= 248
|
||||
clamped[31] &= 63
|
||||
clamped[31] |= 64
|
||||
return bytes(clamped)
|
||||
|
||||
|
||||
def derive_public_key(private_key: bytes) -> bytes:
|
||||
"""
|
||||
Derive the Ed25519 public key from a MeshCore private key.
|
||||
|
||||
**MeshCore Key Format:**
|
||||
MeshCore stores a non-standard Ed25519 private key format:
|
||||
- First 32 bytes: The scalar (already post-SHA-512 and clamped)
|
||||
- Last 32 bytes: The signing prefix (used during signature generation)
|
||||
|
||||
Standard Ed25519 libraries expect a 32-byte seed and derive the scalar via
|
||||
SHA-512. Using `SigningKey(private_bytes)` will produce the WRONG public key.
|
||||
|
||||
To derive the correct public key, we use direct scalar × basepoint multiplication
|
||||
with the noclamp variant (since the scalar is already clamped).
|
||||
|
||||
Args:
|
||||
private_key: 64-byte MeshCore private key (or just the first 32 bytes)
|
||||
|
||||
Returns:
|
||||
32-byte Ed25519 public key
|
||||
"""
|
||||
scalar = private_key[:32]
|
||||
# Use noclamp because MeshCore stores already-clamped scalars
|
||||
return nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(scalar)
|
||||
|
||||
|
||||
def derive_shared_secret(our_private_key: bytes, their_public_key: bytes) -> bytes:
|
||||
"""
|
||||
Derive ECDH shared secret from Ed25519 keys.
|
||||
|
||||
MeshCore uses Ed25519 keys, but ECDH requires X25519. This function:
|
||||
1. Clamps our private key scalar for X25519 (idempotent since already clamped)
|
||||
2. Converts their Ed25519 public key to X25519
|
||||
3. Performs X25519 scalar multiplication to get the shared secret
|
||||
|
||||
**MeshCore Key Format:**
|
||||
MeshCore private keys store the scalar directly (not a seed), so the first
|
||||
32 bytes are already the post-SHA-512 clamped scalar. See `derive_public_key`
|
||||
for details.
|
||||
|
||||
Args:
|
||||
our_private_key: 64-byte MeshCore private key (only first 32 bytes used)
|
||||
their_public_key: Their 32-byte Ed25519 public key
|
||||
|
||||
Returns:
|
||||
32-byte shared secret
|
||||
"""
|
||||
# Clamp the first 32 bytes of our private key (idempotent for MeshCore keys)
|
||||
clamped = _clamp_scalar(our_private_key[:32])
|
||||
|
||||
# Convert their Ed25519 public key to X25519
|
||||
x25519_pub = nacl.bindings.crypto_sign_ed25519_pk_to_curve25519(their_public_key)
|
||||
|
||||
# Perform X25519 ECDH
|
||||
return nacl.bindings.crypto_scalarmult(clamped, x25519_pub)
|
||||
|
||||
|
||||
def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDirectMessage | None:
|
||||
"""
|
||||
Decrypt a TEXT_MESSAGE payload using the ECDH shared secret.
|
||||
|
||||
TEXT_MESSAGE payload structure:
|
||||
- dest_hash (1 byte): First byte of destination public key
|
||||
- src_hash (1 byte): First byte of sender public key
|
||||
- mac (2 bytes): First 2 bytes of HMAC-SHA256(shared_secret, ciphertext)
|
||||
- ciphertext (rest): AES-128-ECB encrypted content
|
||||
|
||||
Decrypted content structure:
|
||||
- timestamp (4 bytes, little-endian)
|
||||
- flags (1 byte)
|
||||
- message text (null-padded)
|
||||
|
||||
Args:
|
||||
payload: The TEXT_MESSAGE payload bytes
|
||||
shared_secret: 32-byte ECDH shared secret
|
||||
|
||||
Returns:
|
||||
DecryptedDirectMessage if successful, None otherwise
|
||||
"""
|
||||
if len(payload) < 4:
|
||||
return None
|
||||
|
||||
dest_hash = format(payload[0], "02x")
|
||||
src_hash = format(payload[1], "02x")
|
||||
mac = payload[2:4]
|
||||
ciphertext = payload[4:]
|
||||
|
||||
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
|
||||
# AES requires 16-byte blocks
|
||||
return None
|
||||
|
||||
# Verify MAC: HMAC-SHA256(shared_secret, ciphertext)[:2]
|
||||
calculated_mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
|
||||
if calculated_mac != mac:
|
||||
return None
|
||||
|
||||
# Decrypt using AES-128-ECB with shared_secret[:16]
|
||||
try:
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(ciphertext)
|
||||
except Exception as e:
|
||||
logger.debug("AES decryption failed for DM: %s", e)
|
||||
return None
|
||||
|
||||
if len(decrypted) < 5:
|
||||
return None
|
||||
|
||||
# Parse decrypted content
|
||||
timestamp = int.from_bytes(decrypted[0:4], "little")
|
||||
flags = decrypted[4]
|
||||
|
||||
# Extract message text (UTF-8, null-padded)
|
||||
message_bytes = decrypted[5:]
|
||||
try:
|
||||
message_text = message_bytes.decode("utf-8")
|
||||
# Remove null terminator and any padding
|
||||
message_text = message_text.rstrip("\x00")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
return DecryptedDirectMessage(
|
||||
timestamp=timestamp,
|
||||
flags=flags,
|
||||
message=message_text,
|
||||
dest_hash=dest_hash,
|
||||
src_hash=src_hash,
|
||||
)
|
||||
|
||||
|
||||
def try_decrypt_dm(
|
||||
raw_packet: bytes,
|
||||
our_private_key: bytes,
|
||||
their_public_key: bytes,
|
||||
our_public_key: bytes | None = None,
|
||||
) -> DecryptedDirectMessage | None:
|
||||
"""
|
||||
Try to decrypt a raw packet as a direct message.
|
||||
|
||||
This performs several checks before attempting expensive ECDH:
|
||||
1. Packet must be TEXT_MESSAGE type
|
||||
2. dest_hash must match first byte of our public key (or their key for outbound)
|
||||
3. src_hash must match first byte of their public key (or our key for outbound)
|
||||
|
||||
Args:
|
||||
raw_packet: The complete raw packet bytes
|
||||
our_private_key: Our 64-byte Ed25519 private key
|
||||
their_public_key: Their 32-byte Ed25519 public key
|
||||
our_public_key: Our 32-byte Ed25519 public key (optional, for bidirectional check)
|
||||
|
||||
Returns:
|
||||
DecryptedDirectMessage if successful, None otherwise
|
||||
"""
|
||||
packet_info = parse_packet(raw_packet)
|
||||
if packet_info is None:
|
||||
return None
|
||||
|
||||
# Only TEXT_MESSAGE packets can be decrypted as DMs
|
||||
if packet_info.payload_type != PayloadType.TEXT_MESSAGE:
|
||||
return None
|
||||
|
||||
if len(packet_info.payload) < 4:
|
||||
return None
|
||||
|
||||
# Extract dest/src hashes from payload
|
||||
dest_hash = packet_info.payload[0]
|
||||
src_hash = packet_info.payload[1]
|
||||
|
||||
# Check if this packet is for us (inbound: them -> us)
|
||||
their_first_byte = their_public_key[0]
|
||||
is_inbound = src_hash == their_first_byte
|
||||
|
||||
# Check if this packet is from us (outbound: us -> them)
|
||||
is_outbound = False
|
||||
if our_public_key is not None:
|
||||
our_first_byte = our_public_key[0]
|
||||
is_outbound = src_hash == our_first_byte and dest_hash == their_first_byte
|
||||
|
||||
if not is_inbound and not is_outbound:
|
||||
# Packet doesn't match this contact conversation
|
||||
return None
|
||||
|
||||
# Derive shared secret and attempt decryption
|
||||
try:
|
||||
shared_secret = derive_shared_secret(our_private_key, their_public_key)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to derive shared secret: %s", e)
|
||||
return None
|
||||
|
||||
return decrypt_direct_message(packet_info.payload, shared_secret)
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Ephemeral keystore for storing sensitive keys in memory.
|
||||
|
||||
The private key is stored in memory only and is never persisted to disk.
|
||||
It's exported from the radio on startup and reconnect, then used for
|
||||
server-side decryption of direct messages.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from app.decoder import derive_public_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore import MeshCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# In-memory storage for the private key and derived public key
|
||||
_private_key: bytes | None = None
|
||||
_public_key: bytes | None = None
|
||||
|
||||
|
||||
def set_private_key(key: bytes) -> None:
|
||||
"""Store the private key in memory and derive the public key.
|
||||
|
||||
Args:
|
||||
key: 64-byte Ed25519 private key in MeshCore format
|
||||
"""
|
||||
global _private_key, _public_key
|
||||
if len(key) != 64:
|
||||
raise ValueError(f"Private key must be 64 bytes, got {len(key)}")
|
||||
_private_key = key
|
||||
_public_key = derive_public_key(key)
|
||||
logger.info("Private key stored in keystore (public key: %s...)", _public_key.hex()[:12])
|
||||
|
||||
|
||||
def get_private_key() -> bytes | None:
|
||||
"""Get the stored private key.
|
||||
|
||||
Returns:
|
||||
The 64-byte private key, or None if not set
|
||||
"""
|
||||
return _private_key
|
||||
|
||||
|
||||
def get_public_key() -> bytes | None:
|
||||
"""Get the derived public key.
|
||||
|
||||
Returns:
|
||||
The 32-byte public key derived from the private key, or None if not set
|
||||
"""
|
||||
return _public_key
|
||||
|
||||
|
||||
def has_private_key() -> bool:
|
||||
"""Check if a private key is stored.
|
||||
|
||||
Returns:
|
||||
True if a private key is available
|
||||
"""
|
||||
return _private_key is not None
|
||||
|
||||
|
||||
def clear_private_key() -> None:
|
||||
"""Clear the stored private key from memory."""
|
||||
global _private_key, _public_key
|
||||
_private_key = None
|
||||
_public_key = None
|
||||
logger.info("Private key cleared from keystore")
|
||||
|
||||
|
||||
async def export_and_store_private_key(mc: "MeshCore") -> bool:
|
||||
"""Export private key from the radio and store it in the keystore.
|
||||
|
||||
This should be called on startup and after each reconnect.
|
||||
|
||||
Args:
|
||||
mc: Connected MeshCore instance
|
||||
|
||||
Returns:
|
||||
True if the private key was successfully exported and stored
|
||||
"""
|
||||
logger.info("Exporting private key from radio...")
|
||||
try:
|
||||
result = await mc.commands.export_private_key()
|
||||
|
||||
if result.type == EventType.PRIVATE_KEY:
|
||||
private_key_bytes = result.payload["private_key"]
|
||||
set_private_key(private_key_bytes)
|
||||
return True
|
||||
elif result.type == EventType.DISABLED:
|
||||
logger.warning(
|
||||
"Private key export disabled on radio firmware. "
|
||||
"Server-side DM decryption will not be available. "
|
||||
"Enable ENABLE_PRIVATE_KEY_EXPORT=1 in firmware to enable this feature."
|
||||
)
|
||||
return False
|
||||
else:
|
||||
logger.error("Failed to export private key: %s", result.payload)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Error exporting private key: %s", e)
|
||||
return False
|
||||
@@ -48,6 +48,11 @@ async def lifespan(app: FastAPI):
|
||||
if radio_manager.meshcore:
|
||||
register_event_handlers(radio_manager.meshcore)
|
||||
|
||||
# Export and store private key for server-side DM decryption
|
||||
from app.keystore import export_and_store_private_key
|
||||
|
||||
await export_and_store_private_key(radio_manager.meshcore)
|
||||
|
||||
# Sync radio clock with system time
|
||||
await sync_radio_time()
|
||||
|
||||
|
||||
@@ -55,6 +55,17 @@ class Contact(BaseModel):
|
||||
}
|
||||
|
||||
|
||||
class CreateContactRequest(BaseModel):
|
||||
"""Request to create a new contact."""
|
||||
|
||||
public_key: str = Field(min_length=64, max_length=64, description="Public key (64-char hex)")
|
||||
name: str | None = Field(default=None, description="Display name for the contact")
|
||||
try_historical: bool = Field(
|
||||
default=False,
|
||||
description="Attempt to decrypt historical DM packets for this contact",
|
||||
)
|
||||
|
||||
|
||||
# Contact type constants
|
||||
CONTACT_TYPE_REPEATER = 2
|
||||
|
||||
|
||||
+255
-5
@@ -17,12 +17,15 @@ import logging
|
||||
import time
|
||||
|
||||
from app.decoder import (
|
||||
DecryptedDirectMessage,
|
||||
PacketInfo,
|
||||
PayloadType,
|
||||
parse_advertisement,
|
||||
parse_packet,
|
||||
try_decrypt_dm,
|
||||
try_decrypt_packet_with_channel_key,
|
||||
)
|
||||
from app.keystore import get_private_key, get_public_key, has_private_key
|
||||
from app.models import CONTACT_TYPE_REPEATER, RawPacketBroadcast, RawPacketDecryptedInfo
|
||||
from app.repository import (
|
||||
ChannelRepository,
|
||||
@@ -161,6 +164,133 @@ async def create_message_from_decrypted(
|
||||
return msg_id
|
||||
|
||||
|
||||
async def create_dm_message_from_decrypted(
|
||||
packet_id: int,
|
||||
decrypted: DecryptedDirectMessage,
|
||||
their_public_key: str,
|
||||
our_public_key: str | None,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
outgoing: bool = False,
|
||||
) -> int | None:
|
||||
"""Create a message record from decrypted direct message packet content.
|
||||
|
||||
This is the shared logic for storing decrypted direct messages,
|
||||
used by both real-time packet processing and historical decryption.
|
||||
|
||||
Args:
|
||||
packet_id: ID of the raw packet being processed
|
||||
decrypted: DecryptedDirectMessage from decoder
|
||||
their_public_key: The contact's full 64-char public key (conversation_key)
|
||||
our_public_key: Our public key (to determine direction), or None
|
||||
received_at: When the packet was received (defaults to now)
|
||||
path: Hex-encoded routing path (None for historical decryption)
|
||||
outgoing: Whether this is an outgoing message (we sent it)
|
||||
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
received = received_at or int(time.time())
|
||||
|
||||
# conversation_key is always the other party's public key
|
||||
conversation_key = their_public_key.lower()
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=decrypted.message,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
path=path,
|
||||
outgoing=outgoing,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
# Duplicate message detected
|
||||
existing_msg = await MessageRepository.get_by_content(
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=decrypted.message,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
)
|
||||
if not existing_msg:
|
||||
logger.warning(
|
||||
"Duplicate DM for contact %s but couldn't find existing",
|
||||
conversation_key[:12],
|
||||
)
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
"Duplicate DM for contact %s (msg_id=%d, outgoing=%s) - adding path",
|
||||
conversation_key[:12],
|
||||
existing_msg.id,
|
||||
existing_msg.outgoing,
|
||||
)
|
||||
|
||||
# Add path if provided
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received)
|
||||
else:
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
# Increment ack count for outgoing messages (echo confirmation)
|
||||
if existing_msg.outgoing:
|
||||
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
|
||||
else:
|
||||
ack_count = await MessageRepository.get_ack_count(existing_msg.id)
|
||||
|
||||
# Broadcast updated paths
|
||||
broadcast_event(
|
||||
"message_acked",
|
||||
{
|
||||
"message_id": existing_msg.id,
|
||||
"ack_count": ack_count,
|
||||
"paths": [p.model_dump() for p in paths] if paths else [],
|
||||
},
|
||||
)
|
||||
|
||||
# Mark this packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Stored direct message %d for contact %s (outgoing=%s)",
|
||||
msg_id,
|
||||
conversation_key[:12],
|
||||
outgoing,
|
||||
)
|
||||
|
||||
# Mark the raw packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
# Build paths array for broadcast
|
||||
paths = [{"path": path or "", "received_at": received}] if path is not None else None
|
||||
|
||||
# Broadcast new message to connected clients
|
||||
broadcast_event(
|
||||
"message",
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "PRIV",
|
||||
"conversation_key": conversation_key,
|
||||
"text": decrypted.message,
|
||||
"sender_timestamp": decrypted.timestamp,
|
||||
"received_at": received,
|
||||
"paths": paths,
|
||||
"txt_type": 0,
|
||||
"signature": None,
|
||||
"outgoing": outgoing,
|
||||
"acked": 0,
|
||||
},
|
||||
)
|
||||
|
||||
# Update contact's last_contacted timestamp (for sorting)
|
||||
await ContactRepository.update_last_contacted(conversation_key, received)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
async def process_raw_packet(
|
||||
raw_bytes: bytes,
|
||||
timestamp: int | None = None,
|
||||
@@ -223,11 +353,11 @@ async def process_raw_packet(
|
||||
# Only process new advertisements (duplicates don't add value)
|
||||
await _process_advertisement(raw_bytes, ts, packet_info)
|
||||
|
||||
# TODO: Add TEXT_MESSAGE (direct message) decryption when private key is available
|
||||
# elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
# decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info)
|
||||
# if decrypt_result:
|
||||
# result.update(decrypt_result)
|
||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
# Try to decrypt direct messages using stored private key and known contacts
|
||||
decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info)
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
# Always broadcast raw packet for the packet feed UI (even duplicates)
|
||||
# This enables the frontend cracker to see all incoming packets in real-time
|
||||
@@ -416,3 +546,123 @@ async def _process_advertisement(
|
||||
from app.radio_sync import sync_recent_contacts_to_radio
|
||||
|
||||
asyncio.create_task(sync_recent_contacts_to_radio())
|
||||
|
||||
|
||||
async def _process_direct_message(
|
||||
raw_bytes: bytes,
|
||||
packet_id: int,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Process a TEXT_MESSAGE (direct message) packet.
|
||||
|
||||
Uses the stored private key and tries to decrypt with known contacts.
|
||||
The src_hash (first byte of sender's public key) is used to narrow down
|
||||
candidate contacts for decryption.
|
||||
"""
|
||||
if not has_private_key():
|
||||
# No private key available - can't decrypt DMs
|
||||
return None
|
||||
|
||||
private_key = get_private_key()
|
||||
our_public_key = get_public_key()
|
||||
if private_key is None or our_public_key is None:
|
||||
return None
|
||||
|
||||
# Parse packet to get the payload for src_hash extraction
|
||||
if packet_info is None:
|
||||
packet_info = parse_packet(raw_bytes)
|
||||
if packet_info is None or packet_info.payload is None:
|
||||
return None
|
||||
|
||||
# Extract src_hash from payload (second byte: [dest_hash:1][src_hash:1][MAC:2][ciphertext])
|
||||
if len(packet_info.payload) < 4:
|
||||
return None
|
||||
|
||||
dest_hash = format(packet_info.payload[0], "02x").lower()
|
||||
src_hash = format(packet_info.payload[1], "02x").lower()
|
||||
|
||||
# Check if this message involves us (either as sender or recipient)
|
||||
our_first_byte = format(our_public_key[0], "02x").lower()
|
||||
|
||||
# Determine direction based on which hash matches us:
|
||||
# - dest_hash == us AND src_hash != us -> incoming (addressed to us from someone else)
|
||||
# - src_hash == us AND dest_hash != us -> outgoing (we sent to someone else)
|
||||
# - Both match us -> ambiguous (our first byte matches contact's), default to incoming
|
||||
# - Neither matches us -> not our message
|
||||
if dest_hash == our_first_byte and src_hash != our_first_byte:
|
||||
is_outgoing = False # Definitely incoming
|
||||
elif src_hash == our_first_byte and dest_hash != our_first_byte:
|
||||
is_outgoing = True # Definitely outgoing
|
||||
elif dest_hash == our_first_byte and src_hash == our_first_byte:
|
||||
# Ambiguous: our first byte matches contact's first byte (1/256 chance)
|
||||
# Default to incoming since dest_hash matching us is more indicative
|
||||
is_outgoing = False
|
||||
logger.debug("Ambiguous DM direction (first bytes match), defaulting to incoming")
|
||||
else:
|
||||
# Neither hash matches us - not our message
|
||||
return None
|
||||
|
||||
# Find candidate contacts based on the relevant hash
|
||||
# For incoming: match src_hash (sender's first byte)
|
||||
# For outgoing: match dest_hash (recipient's first byte)
|
||||
match_hash = dest_hash if is_outgoing else src_hash
|
||||
|
||||
# Get all contacts and filter by first byte of public key
|
||||
contacts = await ContactRepository.get_all(limit=1000)
|
||||
candidate_contacts = [c for c in contacts if c.public_key.lower().startswith(match_hash)]
|
||||
|
||||
if not candidate_contacts:
|
||||
logger.debug(
|
||||
"No contacts found matching hash %s for DM decryption",
|
||||
match_hash,
|
||||
)
|
||||
return None
|
||||
|
||||
# Try decrypting with each candidate contact
|
||||
for contact in candidate_contacts:
|
||||
try:
|
||||
contact_public_key = bytes.fromhex(contact.public_key)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# For incoming messages, pass our_public_key to enable the dest_hash filter
|
||||
# For outgoing messages, skip the filter (dest_hash is the recipient, not us)
|
||||
result = try_decrypt_dm(
|
||||
raw_bytes,
|
||||
private_key,
|
||||
contact_public_key,
|
||||
our_public_key=our_public_key if not is_outgoing else None,
|
||||
)
|
||||
|
||||
if result is not None:
|
||||
# Successfully decrypted!
|
||||
logger.debug(
|
||||
"Decrypted DM %s contact %s: %s",
|
||||
"to" if is_outgoing else "from",
|
||||
contact.name or contact.public_key[:12],
|
||||
result.message[:50] if result.message else "",
|
||||
)
|
||||
|
||||
# Create message (or add path to existing if duplicate)
|
||||
msg_id = await create_dm_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
decrypted=result,
|
||||
their_public_key=contact.public_key,
|
||||
our_public_key=our_public_key.hex(),
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info.path else None,
|
||||
outgoing=is_outgoing,
|
||||
)
|
||||
|
||||
return {
|
||||
"decrypted": True,
|
||||
"contact_name": contact.name,
|
||||
"sender": contact.name or contact.public_key[:12],
|
||||
"message_id": msg_id,
|
||||
}
|
||||
|
||||
# Couldn't decrypt with any known contact
|
||||
logger.debug("Could not decrypt DM with any of %d candidate contacts", len(candidate_contacts))
|
||||
return None
|
||||
|
||||
@@ -231,9 +231,11 @@ class RadioManager:
|
||||
if await self.reconnect():
|
||||
# Re-register event handlers after successful reconnect
|
||||
from app.event_handlers import register_event_handlers
|
||||
from app.keystore import export_and_store_private_key
|
||||
|
||||
if self._meshcore:
|
||||
register_event_handlers(self._meshcore)
|
||||
await export_and_store_private_key(self._meshcore)
|
||||
await self._meshcore.start_auto_message_fetching()
|
||||
logger.info("Event handlers re-registered after auto-reconnect")
|
||||
|
||||
|
||||
+23
-1
@@ -6,7 +6,7 @@ from hashlib import sha256
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
from app.decoder import extract_payload
|
||||
from app.decoder import PayloadType, extract_payload, get_packet_payload_type
|
||||
from app.models import Channel, Contact, Message, MessagePath, RawPacket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -681,3 +681,25 @@ class RawPacketRepository:
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted_text_messages() -> list[tuple[int, bytes, int]]:
|
||||
"""Get all undecrypted TEXT_MESSAGE packets as (id, data, timestamp) tuples.
|
||||
|
||||
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
||||
These are direct messages that can be decrypted with contact ECDH keys.
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
# Filter for TEXT_MESSAGE packets
|
||||
result = []
|
||||
for row in rows:
|
||||
data = bytes(row["data"])
|
||||
payload_type = get_packet_payload_type(data)
|
||||
if payload_type == PayloadType.TEXT_MESSAGE:
|
||||
result.append((row["id"], data, row["timestamp"]))
|
||||
|
||||
return result
|
||||
|
||||
+108
-1
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
@@ -11,6 +11,7 @@ from app.models import (
|
||||
CommandRequest,
|
||||
CommandResponse,
|
||||
Contact,
|
||||
CreateContactRequest,
|
||||
NeighborInfo,
|
||||
TelemetryRequest,
|
||||
TelemetryResponse,
|
||||
@@ -18,6 +19,7 @@ from app.models import (
|
||||
from app.radio import radio_manager
|
||||
from app.radio_sync import pause_polling
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.packets import _run_historical_dm_decryption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -70,6 +72,111 @@ async def list_contacts(
|
||||
return await ContactRepository.get_all(limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("", response_model=Contact)
|
||||
async def create_contact(
|
||||
request: CreateContactRequest, background_tasks: BackgroundTasks
|
||||
) -> Contact:
|
||||
"""Create a new contact in the database.
|
||||
|
||||
If the contact already exists, updates the name (if provided).
|
||||
If try_historical is True, attempts to decrypt historical DM packets.
|
||||
"""
|
||||
# Validate hex format
|
||||
try:
|
||||
contact_public_key_bytes = bytes.fromhex(request.public_key)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail="Invalid public key: must be valid hex") from e
|
||||
|
||||
# Check if contact already exists
|
||||
existing = await ContactRepository.get_by_key_or_prefix(request.public_key)
|
||||
if existing:
|
||||
# Update name if provided
|
||||
if request.name:
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": existing.public_key,
|
||||
"name": request.name,
|
||||
"type": existing.type,
|
||||
"flags": existing.flags,
|
||||
"last_path": existing.last_path,
|
||||
"last_path_len": existing.last_path_len,
|
||||
"last_advert": existing.last_advert,
|
||||
"lat": existing.lat,
|
||||
"lon": existing.lon,
|
||||
"last_seen": existing.last_seen,
|
||||
"on_radio": existing.on_radio,
|
||||
"last_contacted": existing.last_contacted,
|
||||
}
|
||||
)
|
||||
existing.name = request.name
|
||||
|
||||
# Trigger historical decryption if requested (even for existing contacts)
|
||||
if request.try_historical:
|
||||
await _start_historical_dm_decryption(
|
||||
background_tasks, contact_public_key_bytes, request.public_key
|
||||
)
|
||||
|
||||
return existing
|
||||
|
||||
# Create new contact
|
||||
contact_data = {
|
||||
"public_key": request.public_key,
|
||||
"name": request.name,
|
||||
"type": 0, # Unknown
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
"last_seen": None,
|
||||
"on_radio": False,
|
||||
"last_contacted": None,
|
||||
}
|
||||
await ContactRepository.upsert(contact_data)
|
||||
logger.info("Created contact %s", request.public_key[:12])
|
||||
|
||||
# Trigger historical decryption if requested
|
||||
if request.try_historical:
|
||||
await _start_historical_dm_decryption(
|
||||
background_tasks, contact_public_key_bytes, request.public_key
|
||||
)
|
||||
|
||||
return Contact(**contact_data)
|
||||
|
||||
|
||||
async def _start_historical_dm_decryption(
|
||||
background_tasks: BackgroundTasks,
|
||||
contact_public_key_bytes: bytes,
|
||||
contact_public_key_hex: str,
|
||||
) -> None:
|
||||
"""Start historical DM decryption using the stored private key."""
|
||||
from app.keystore import get_private_key, has_private_key
|
||||
from app.websocket import broadcast_error
|
||||
|
||||
if not has_private_key():
|
||||
logger.warning(
|
||||
"Cannot start historical DM decryption: private key not available. "
|
||||
"Ensure radio firmware has ENABLE_PRIVATE_KEY_EXPORT=1."
|
||||
)
|
||||
broadcast_error(
|
||||
"Cannot decrypt historical DMs",
|
||||
"Private key not available. Radio firmware may need ENABLE_PRIVATE_KEY_EXPORT=1.",
|
||||
)
|
||||
return
|
||||
|
||||
private_key_bytes = get_private_key()
|
||||
assert private_key_bytes is not None # Guaranteed by has_private_key check
|
||||
|
||||
logger.info("Starting historical DM decryption for contact %s", contact_public_key_hex[:12])
|
||||
background_tasks.add_task(
|
||||
_run_historical_dm_decryption,
|
||||
private_key_bytes,
|
||||
contact_public_key_bytes,
|
||||
contact_public_key_hex.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=Contact)
|
||||
async def get_contact(public_key: str) -> Contact:
|
||||
"""Get a specific contact by public key or prefix."""
|
||||
|
||||
+165
-4
@@ -5,8 +5,13 @@ from fastapi import APIRouter, BackgroundTasks
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.database import db
|
||||
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
||||
from app.packet_processor import create_message_from_decrypted
|
||||
from app.decoder import (
|
||||
derive_public_key,
|
||||
parse_packet,
|
||||
try_decrypt_dm,
|
||||
try_decrypt_packet_with_channel_key,
|
||||
)
|
||||
from app.packet_processor import create_dm_message_from_decrypted, create_message_from_decrypted
|
||||
from app.repository import RawPacketRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -21,6 +26,14 @@ class DecryptRequest(BaseModel):
|
||||
channel_name: str | None = Field(
|
||||
default=None, description="Channel name (for hashtag channels, key derived from name)"
|
||||
)
|
||||
# Fields for contact (DM) decryption
|
||||
private_key: str | None = Field(
|
||||
default=None,
|
||||
description="Our private key as hex (64 bytes = 128 chars, Ed25519 seed + pubkey)",
|
||||
)
|
||||
contact_public_key: str | None = Field(
|
||||
default=None, description="Contact's public key as hex (32 bytes = 64 chars)"
|
||||
)
|
||||
|
||||
|
||||
class DecryptResult(BaseModel):
|
||||
@@ -94,6 +107,84 @@ async def _run_historical_decryption(channel_key_bytes: bytes, channel_key_hex:
|
||||
logger.info("Historical decryption complete: %d/%d packets decrypted", decrypted_count, total)
|
||||
|
||||
|
||||
async def _run_historical_dm_decryption(
|
||||
private_key_bytes: bytes,
|
||||
contact_public_key_bytes: bytes,
|
||||
contact_public_key_hex: str,
|
||||
) -> None:
|
||||
"""Background task to decrypt historical DM packets with contact's key."""
|
||||
global _decrypt_progress
|
||||
|
||||
# Get only TEXT_MESSAGE packets (undecrypted)
|
||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
||||
total = len(packets)
|
||||
processed = 0
|
||||
decrypted_count = 0
|
||||
|
||||
_decrypt_progress = DecryptProgress(total=total, processed=0, decrypted=0, in_progress=True)
|
||||
|
||||
logger.info("Starting historical DM decryption of %d TEXT_MESSAGE packets", total)
|
||||
|
||||
# Derive our public key from the private key using Ed25519 scalar multiplication.
|
||||
# Note: MeshCore stores the scalar directly (not a seed), so we use noclamp variant.
|
||||
# See derive_public_key() for details on the MeshCore key format.
|
||||
our_public_key_bytes = derive_public_key(private_key_bytes)
|
||||
|
||||
for packet_id, packet_data, packet_timestamp in packets:
|
||||
# Don't pass our_public_key - we want to decrypt both incoming AND outgoing messages.
|
||||
# The our_public_key filter in try_decrypt_dm only matches incoming (dest_hash == us),
|
||||
# which would skip outgoing messages (where dest_hash == contact).
|
||||
result = try_decrypt_dm(
|
||||
packet_data,
|
||||
private_key_bytes,
|
||||
contact_public_key_bytes,
|
||||
our_public_key=None,
|
||||
)
|
||||
|
||||
if result is not None:
|
||||
# Successfully decrypted - determine if inbound or outbound by checking src_hash
|
||||
src_hash = result.src_hash.lower()
|
||||
our_first_byte = format(our_public_key_bytes[0], "02x").lower()
|
||||
outgoing = src_hash == our_first_byte
|
||||
|
||||
logger.debug(
|
||||
"Decrypted DM packet %d: message=%s (outgoing=%s)",
|
||||
packet_id,
|
||||
result.message[:50] if result.message else "",
|
||||
outgoing,
|
||||
)
|
||||
|
||||
# Extract path from the raw packet for storage
|
||||
packet_info = parse_packet(packet_data)
|
||||
path_hex = packet_info.path.hex() if packet_info else None
|
||||
|
||||
msg_id = await create_dm_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
decrypted=result,
|
||||
their_public_key=contact_public_key_hex,
|
||||
our_public_key=our_public_key_bytes.hex(),
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
outgoing=outgoing,
|
||||
)
|
||||
|
||||
if msg_id is not None:
|
||||
decrypted_count += 1
|
||||
|
||||
processed += 1
|
||||
_decrypt_progress = DecryptProgress(
|
||||
total=total, processed=processed, decrypted=decrypted_count, in_progress=True
|
||||
)
|
||||
|
||||
_decrypt_progress = DecryptProgress(
|
||||
total=total, processed=processed, decrypted=decrypted_count, in_progress=False
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Historical DM decryption complete: %d/%d packets decrypted", decrypted_count, total
|
||||
)
|
||||
|
||||
|
||||
@router.get("/undecrypted/count")
|
||||
async def get_undecrypted_count() -> dict:
|
||||
"""Get the count of undecrypted packets."""
|
||||
@@ -151,12 +242,82 @@ async def decrypt_historical_packets(
|
||||
total_packets=0,
|
||||
message="Must provide channel_key or channel_name",
|
||||
)
|
||||
elif request.key_type == "contact":
|
||||
# Validate required fields for contact decryption
|
||||
if not request.private_key:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Must provide private_key for contact decryption",
|
||||
)
|
||||
if not request.contact_public_key:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Must provide contact_public_key for contact decryption",
|
||||
)
|
||||
|
||||
# Parse private key
|
||||
try:
|
||||
private_key_bytes = bytes.fromhex(request.private_key)
|
||||
if len(private_key_bytes) != 64:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Private key must be 64 bytes (128 hex chars)",
|
||||
)
|
||||
except ValueError:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Invalid hex string for private key",
|
||||
)
|
||||
|
||||
# Parse contact public key
|
||||
try:
|
||||
contact_public_key_bytes = bytes.fromhex(request.contact_public_key)
|
||||
if len(contact_public_key_bytes) != 32:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Contact public key must be 32 bytes (64 hex chars)",
|
||||
)
|
||||
contact_public_key_hex = request.contact_public_key.lower()
|
||||
except ValueError:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Invalid hex string for contact public key",
|
||||
)
|
||||
|
||||
# Get count of undecrypted TEXT_MESSAGE packets
|
||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
||||
count = len(packets)
|
||||
if count == 0:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="No undecrypted TEXT_MESSAGE packets to process",
|
||||
)
|
||||
|
||||
# Start background decryption
|
||||
background_tasks.add_task(
|
||||
_run_historical_dm_decryption,
|
||||
private_key_bytes,
|
||||
contact_public_key_bytes,
|
||||
contact_public_key_hex,
|
||||
)
|
||||
|
||||
return DecryptResult(
|
||||
started=True,
|
||||
total_packets=count,
|
||||
message=f"Started DM decryption of {count} TEXT_MESSAGE packets in background",
|
||||
)
|
||||
else:
|
||||
# Contact decryption not yet supported (requires Ed25519 shared secret)
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Contact key decryption not yet supported",
|
||||
message="key_type must be 'channel' or 'contact'",
|
||||
)
|
||||
|
||||
# Get count of undecrypted packets
|
||||
|
||||
Reference in New Issue
Block a user