Add historical DM decryption

This commit is contained in:
Jack Kingsman
2026-01-18 21:22:22 -08:00
parent 30e3eb47be
commit 42572aa234
22 changed files with 1761 additions and 53 deletions
+24 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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()
+11
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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