Files
meshcore-bot/modules/service_plugins/map_uploader_service.py
Adam Gessaman 67c16995da fix: Enhance logging configuration handling
- Updated logging setup to use default values when the [Logging] section is missing, ensuring consistent behavior.
- Improved handling of colored output and log file configuration based on the presence of the [Logging] section across multiple modules.
- Refactored related code in core and service plugins for better clarity and maintainability.
2026-02-12 17:19:29 -08:00

888 lines
34 KiB
Python

#!/usr/bin/env python3
"""
Map Uploader Service for MeshCore Bot
Uploads node adverts to map.meshcore.dev
Adapted from map.meshcore.dev-uploader Node.js implementation
"""
import asyncio
import json
import logging
import hashlib
import time
import copy
from typing import Optional, Dict, Any
# Import meshcore
import meshcore
from meshcore import EventType
# Import bot's enums
from ..enums import AdvertFlags, PayloadType
# Import HTTP client
try:
import aiohttp
AIOHTTP_AVAILABLE = True
except ImportError:
aiohttp = None
AIOHTTP_AVAILABLE = False
# Import cryptography for signature verification
try:
from cryptography.hazmat.primitives.asymmetric import ed25519
CRYPTOGRAPHY_AVAILABLE = True
except ImportError:
CRYPTOGRAPHY_AVAILABLE = False
ed25519 = None # type: ignore
# Import private key utilities
from .packet_capture_utils import (
read_private_key_file,
hex_to_bytes,
bytes_to_hex
)
# Import base service
from .base_service import BaseServicePlugin
# Import utilities
from ..utils import resolve_path
class MapUploaderService(BaseServicePlugin):
"""Map uploader service.
Uploads node adverts relative to the MeshCore network to map.meshcore.dev.
Listens for ADVERT packets and uploads them to the centralized map service.
Handles signing of data using the device's private key to ensure authenticity.
"""
config_section = 'MapUploader' # Explicit config section
description = "Uploads node adverts to map.meshcore.dev"
def __init__(self, bot: Any):
"""Initialize map uploader service.
Args:
bot: The bot instance.
"""
super().__init__(bot)
# Setup logging
self.logger = logging.getLogger('MapUploaderService')
self.logger.setLevel(bot.logger.level)
# Clear any existing handlers to prevent duplicates
self.logger.handlers.clear()
# Use the same formatter as the bot (colored if enabled)
bot_formatter = None
for handler in bot.logger.handlers:
if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler):
bot_formatter = handler.formatter
break
# If no formatter found, create one matching bot's style
if not bot_formatter:
try:
import colorlog
colored = (bot.config.getboolean('Logging', 'colored_output', fallback=True)
if bot.config.has_section('Logging') else True)
if colored:
bot_formatter = colorlog.ColoredFormatter(
'%(log_color)s%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
log_colors={
'DEBUG': 'cyan',
'INFO': 'green',
'WARNING': 'yellow',
'ERROR': 'red',
'CRITICAL': 'red,bg_white',
}
)
else:
bot_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
except ImportError:
bot_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Add console handler with bot's formatter
console_handler = logging.StreamHandler()
console_handler.setFormatter(bot_formatter)
self.logger.addHandler(console_handler)
# Also add file handler to write to the same log file as the bot (skip if no [Logging] section)
log_file = (bot.config.get('Logging', 'log_file', fallback='meshcore_bot.log')
if bot.config.has_section('Logging') else '')
if log_file:
# Resolve log file path (relative paths resolved from bot root, absolute paths used as-is)
log_file = resolve_path(log_file, bot.bot_root)
file_handler = logging.FileHandler(log_file)
file_handler.setFormatter(bot_formatter)
self.logger.addHandler(file_handler)
# Prevent propagation to root logger
self.logger.propagate = False
# Load configuration
self._load_config()
# Connection state
self.connected = False
# Track seen adverts: {pubkey: last_timestamp}
# This prevents duplicate uploads and replay attacks
# We'll periodically clean old entries to prevent unbounded growth
self.seen_adverts: Dict[str, int] = {}
# Device keys and info
self.private_key_hex: Optional[str] = None
self.public_key_hex: Optional[str] = None
self.radio_params: Dict[str, Any] = {}
# HTTP session
# HTTP session
self.http_session: Optional[aiohttp.ClientSession] = None # type: ignore
# Event subscriptions
self.event_subscriptions = []
# Exit flag
self.should_exit = False
# Cleanup tracking
self._last_cleanup_time = 0
self._cleanup_interval = 3600 # Clean up every hour
self.logger.info("Map uploader service initialized")
def _load_config(self) -> None:
"""Load configuration from bot's config."""
config = self.bot.config
# Check if enabled
self.enabled = config.getboolean('MapUploader', 'enabled', fallback=False)
# API URL
self.api_url = config.get(
'MapUploader',
'api_url',
fallback='https://map.meshcore.dev/api/v1/uploader/node'
)
# Private key path (optional, will fetch from device if not provided)
self.private_key_path = config.get('MapUploader', 'private_key_path', fallback=None)
if self.private_key_path:
self.private_key_hex = read_private_key_file(self.private_key_path)
if not self.private_key_hex:
self.logger.warning(f"Could not load private key from {self.private_key_path}")
# Minimum time between re-uploads (seconds)
self.min_reupload_interval = config.getint('MapUploader', 'min_reupload_interval', fallback=3600)
# Verbose logging
self.verbose = config.getboolean('MapUploader', 'verbose', fallback=False)
@property
def meshcore(self) -> Any:
"""Get meshcore connection from bot (always current).
Returns:
Any: The meshcore connection object or None.
"""
return self.bot.meshcore if self.bot else None
async def start(self) -> None:
"""Start the map uploader service.
Initializes connections, fetches device keys, and registers event handlers.
Checks for required dependencies (aiohttp, cryptography) before starting.
"""
if not self.enabled:
self.logger.info("Map uploader service is disabled")
return
# Check dependencies
if not AIOHTTP_AVAILABLE:
self.logger.error("aiohttp is required for map uploader service. Install with: pip install aiohttp")
return
if not CRYPTOGRAPHY_AVAILABLE:
self.logger.error("cryptography is required for signature verification. Install with: pip install cryptography")
return
# Wait for bot to be connected
max_wait = 30 # seconds
wait_time = 0
while (not self.bot.connected or not self.meshcore) and wait_time < max_wait:
await asyncio.sleep(0.5)
wait_time += 0.5
if not self.bot.connected or not self.meshcore:
self.logger.warning("Bot not connected after waiting, cannot start map uploader")
return
self.logger.info("Starting map uploader service...")
# Fetch device info and private key
await self._fetch_device_info()
await self._fetch_private_key()
if not self.private_key_hex:
self.logger.error("Could not obtain private key. Map uploader cannot sign uploads.")
return
if not self.public_key_hex:
self.logger.error("Could not obtain public key. Map uploader cannot sign uploads.")
return
# Create HTTP session
self.http_session = aiohttp.ClientSession() # type: ignore
# Setup event handlers
await self._setup_event_handlers()
self.connected = True
self._running = True
self.logger.info("Map uploader service started")
async def stop(self) -> None:
"""Stop the map uploader service.
Closes connections to the map service and the bot's meshcore.
Cleans up resources and event subscriptions.
"""
self.logger.info("Stopping map uploader service...")
self.should_exit = True
self._running = False
self.connected = False
# Clean up event subscriptions
self._cleanup_event_subscriptions()
# Close HTTP session
if self.http_session:
await self.http_session.close()
self.http_session = None
# Clear seen_adverts to free memory
self.seen_adverts.clear()
# Close file handlers
for handler in self.logger.handlers[:]:
if isinstance(handler, logging.FileHandler):
handler.close()
self.logger.removeHandler(handler)
self.logger.info("Map uploader service stopped")
async def _fetch_private_key(self) -> None:
"""Fetch private key from device if not already loaded.
Attempts to read the private key from the connected MeshCore device
for signing map uploads. This is required for valid uploads.
"""
if self.private_key_hex:
self.logger.debug("Private key already loaded from file")
return
if not self.meshcore or not self.meshcore.is_connected:
self.logger.warning("Cannot fetch private key: not connected")
return
try:
from .packet_capture_utils import _fetch_private_key_from_device
self.logger.info("Fetching private key from device...")
self.private_key_hex = await _fetch_private_key_from_device(self.meshcore)
if self.private_key_hex:
self.logger.info("✓ Successfully fetched private key from device")
else:
self.logger.warning("Could not fetch private key from device (may be disabled)")
except Exception as e:
self.logger.error(f"Error fetching private key from device: {e}")
async def _fetch_device_info(self) -> None:
"""Fetch device info (public key and radio parameters).
Retrieve public key and LoRa radio settings (frequency, coding rate, etc.)
from the device self_info. These parameters are sent with map uploads.
"""
if not self.meshcore or not self.meshcore.is_connected:
self.logger.warning("Cannot fetch device info: not connected")
return
try:
# Get self info from meshcore.self_info (it's a property, not a method)
if not hasattr(self.meshcore, 'self_info') or not self.meshcore.self_info:
self.logger.warning("Device self_info not available")
# Use 0s to indicate unknown values
self.radio_params = {
'freq': 0,
'cr': 0,
'sf': 0,
'bw': 0
}
return
self_info = self.meshcore.self_info
# Extract public key (handle both dict and object formats)
if isinstance(self_info, dict):
public_key = self_info.get('public_key', '')
elif hasattr(self_info, 'public_key'):
public_key = self_info.public_key
else:
public_key = None
if public_key:
if isinstance(public_key, bytes):
self.public_key_hex = bytes_to_hex(public_key)
elif isinstance(public_key, str):
self.public_key_hex = public_key.replace('0x', '').replace(' ', '').lower()
else:
self.public_key_hex = str(public_key)
self.logger.debug(f"Got public key: {self.public_key_hex[:16]}...")
else:
self.logger.warning("Public key not found in self_info")
# Extract radio parameters (handle both dict and object formats)
# Use 0 as fallback to indicate unknown values
if isinstance(self_info, dict):
self.radio_params = {
'freq': self_info.get('radio_freq', self_info.get('freq', 0)),
'cr': self_info.get('radio_cr', self_info.get('cr', 0)),
'sf': self_info.get('radio_sf', self_info.get('sf', 0)),
'bw': self_info.get('radio_bw', self_info.get('bw', 0))
}
else:
# Object format
self.radio_params = {
'freq': getattr(self_info, 'radio_freq', getattr(self_info, 'freq', 0)),
'cr': getattr(self_info, 'radio_cr', getattr(self_info, 'cr', 0)),
'sf': getattr(self_info, 'radio_sf', getattr(self_info, 'sf', 0)),
'bw': getattr(self_info, 'radio_bw', getattr(self_info, 'bw', 0))
}
self.logger.debug(f"Radio params: {json.dumps(self.radio_params, indent=2)}")
except Exception as e:
self.logger.error(f"Error fetching device info: {e}", exc_info=True)
# Use 0s to indicate unknown values
self.radio_params = {
'freq': 0,
'cr': 0,
'sf': 0,
'bw': 0
}
async def _setup_event_handlers(self) -> None:
"""Setup event handlers for packet capture.
Subscribes to RX_LOG_DATA events to intercept packets for upload.
"""
if not self.meshcore:
return
# Handle RX log data
async def on_rx_log_data(event, metadata=None):
await self._handle_rx_log_data(event, metadata)
# Subscribe to events
self.meshcore.subscribe(EventType.RX_LOG_DATA, on_rx_log_data)
self.event_subscriptions = [
(EventType.RX_LOG_DATA, on_rx_log_data)
]
self.logger.info("Map uploader event handlers registered")
def _cleanup_event_subscriptions(self) -> None:
"""Clean up event subscriptions.
Clears the list of tracked subscriptions. The actual unsubscription
is handled by the meshcore library when the client disconnects,
but this clears our local tracking.
"""
# Note: meshcore library handles subscription cleanup automatically
self.event_subscriptions = []
async def _cleanup_old_seen_adverts(self, current_timestamp: int) -> None:
"""Clean up old entries from seen_adverts to prevent unbounded memory growth.
Args:
current_timestamp: The current timestamp from the latest packet.
"""
current_time = time.time()
# Only cleanup periodically (not on every packet)
if current_time - self._last_cleanup_time < self._cleanup_interval:
return
self._last_cleanup_time = current_time
# Remove entries older than min_reupload_interval * 2
# This keeps recent entries but removes very old ones
cutoff_timestamp = current_timestamp - (self.min_reupload_interval * 2)
# Count entries before cleanup
initial_count = len(self.seen_adverts)
# Remove old entries
keys_to_remove = [
pubkey for pubkey, last_ts in self.seen_adverts.items()
if last_ts < cutoff_timestamp
]
for pubkey in keys_to_remove:
del self.seen_adverts[pubkey]
removed_count = len(keys_to_remove)
if removed_count > 0:
self.logger.debug(
f"Cleaned up {removed_count} old seen_adverts entries "
f"({initial_count} -> {len(self.seen_adverts)})"
)
# Safety limit: if dictionary still grows too large, use more aggressive cleanup
if len(self.seen_adverts) > 10000:
# Keep only the most recent 5000 entries
sorted_entries = sorted(
self.seen_adverts.items(),
key=lambda x: x[1],
reverse=True
)
self.seen_adverts = dict(sorted_entries[:5000])
self.logger.warning(
f"seen_adverts grew too large, trimmed to 5000 most recent entries"
)
async def _handle_rx_log_data(self, event: Any, metadata: Any = None) -> None:
"""Handle RX log data events.
Args:
event: The event object containing packet data.
metadata: Optional metadata for the event.
"""
try:
# Copy payload immediately to avoid segfault if event is freed
payload = copy.deepcopy(event.payload) if hasattr(event, 'payload') else None
if payload is None:
self.logger.warning("RX log data event has no payload")
return
# Get raw packet data
raw_hex = None
if 'payload' in payload and payload['payload']:
raw_hex = payload['payload']
elif 'raw_hex' in payload and payload['raw_hex']:
raw_hex = payload['raw_hex'][4:] # Skip first 2 bytes (4 hex chars)
if not raw_hex:
return
# Process packet
await self._process_packet(raw_hex)
except Exception as e:
self.logger.error(f"Error handling RX log data: {e}", exc_info=True)
async def _process_packet(self, raw_hex: str) -> None:
"""Process a packet and upload if it's an ADVERT.
Parses the raw packet hex, validates it is an ADVERT, checks for duplicates,
verifies signature, and triggers upload if valid.
Args:
raw_hex: Hex string representation of the raw packet.
"""
try:
# Parse packet to check if it's an ADVERT
byte_data = bytes.fromhex(raw_hex)
if len(byte_data) < 2:
return
# Extract payload type from header
header = byte_data[0]
payload_type = (header >> 2) & 0x0F
if payload_type != PayloadType.ADVERT.value:
return # Not an ADVERT packet
# Extract payload (skip header, transport codes if present, path)
route_type = header & 0x03
has_transport = route_type in [0x00, 0x03] # TRANSPORT_FLOOD or TRANSPORT_DIRECT
offset = 1
if has_transport:
offset += 4 # Skip transport codes
if len(byte_data) <= offset:
return
path_len = byte_data[offset]
offset += 1
# Skip path
if path_len > 0 and len(byte_data) > offset + path_len:
offset += path_len
# Extract payload
payload_bytes = byte_data[offset:]
if len(payload_bytes) < 101:
return # Too short for ADVERT
# Parse advert
advert = self._parse_advert(payload_bytes)
if not advert:
return
# Check if it's a CHAT advert (skip those)
if advert.get('type') == 'CHAT':
return
# Verify signature
if not await self._verify_advert_signature(advert, payload_bytes):
self.logger.warning(f"Ignoring: signature verification failed for {advert.get('public_key', 'unknown')[:16]}...")
return
# Check for replay attacks
pub_key = advert.get('public_key', '')
timestamp = advert.get('advert_time', 0)
if pub_key in self.seen_adverts:
last_timestamp = self.seen_adverts[pub_key]
# Check for replay (timestamp <= last seen)
if timestamp <= last_timestamp:
if self.verbose:
self.logger.debug(f"Ignoring: possible replay attack for {pub_key[:16]}...")
return
# Check if too soon to reupload
if timestamp < last_timestamp + self.min_reupload_interval:
if self.verbose:
self.logger.debug(f"Ignoring: timestamp too new to reupload for {pub_key[:16]}...")
return
# Skip adverts without coordinates or with any coordinate exactly 0.0 (invalid)
lat = advert.get('lat')
lon = advert.get('lon')
if lat is None or lon is None or lat == 0.0 or lon == 0.0:
if self.verbose:
self.logger.debug(f"Ignoring: advert missing or invalid coordinates (lat={lat}, lon={lon}) for {pub_key[:16]}...")
return
# Upload to map
await self._upload_to_map(advert, raw_hex)
# Update seen adverts
self.seen_adverts[pub_key] = timestamp
# Periodically clean up old entries to prevent unbounded memory growth
await self._cleanup_old_seen_adverts(timestamp)
except Exception as e:
self.logger.error(f"Error processing packet: {e}", exc_info=True)
def _parse_advert(self, payload: bytes) -> Optional[Dict[str, Any]]:
"""Parse advert payload.
Args:
payload: Binary payload of the packet.
Returns:
Optional[Dict[str, Any]]: Parsed advert data dictionary or None if invalid.
"""
try:
if len(payload) < 101:
return None
# Advert header
pub_key = payload[0:32]
timestamp = int.from_bytes(payload[32:36], "little")
signature = payload[36:100]
# App data
app_data = payload[100:]
if len(app_data) == 0:
return None
flags_byte = app_data[0]
flags = AdvertFlags(flags_byte)
# Extract type
adv_type = flags_byte & 0x0F
type_str = 'CHAT' if adv_type == AdvertFlags.ADV_TYPE_CHAT.value else \
'REPEATER' if adv_type == AdvertFlags.ADV_TYPE_REPEATER.value else \
'ROOM' if adv_type == AdvertFlags.ADV_TYPE_ROOM.value else \
'SENSOR' if adv_type == AdvertFlags.ADV_TYPE_SENSOR.value else \
f'Type{adv_type}'
advert = {
'public_key': pub_key.hex(),
'advert_time': timestamp,
'signature': signature.hex(),
'type': type_str,
'name': None,
'lat': None,
'lon': None
}
# Parse location data if present
i = 1
if AdvertFlags.ADV_LATLON_MASK in flags:
if len(app_data) >= i + 8:
lat = int.from_bytes(app_data[i:i+4], 'little', signed=True)
lon = int.from_bytes(app_data[i+4:i+8], 'little', signed=True)
advert['lat'] = round(lat / 1000000.0, 6)
advert['lon'] = round(lon / 1000000.0, 6)
i += 8
# Parse feat1 data if present
if AdvertFlags.ADV_FEAT1_MASK in flags:
i += 2
# Parse feat2 data if present
if AdvertFlags.ADV_FEAT2_MASK in flags:
i += 2
# Parse name if present
if AdvertFlags.ADV_NAME_MASK in flags and len(app_data) >= i:
try:
name = app_data[i:].decode('utf-8', errors='ignore').rstrip('\x00')
advert['name'] = name
except Exception:
pass
return advert
except Exception as e:
self.logger.error(f"Error parsing advert: {e}")
return None
async def _verify_advert_signature(self, advert: Dict[str, Any], payload: bytes) -> bool:
"""Verify advert signature using ed25519.
Args:
advert: The parsed advert dictionary containing the signature and public key.
payload: The full binary payload used to verify the signature.
Returns:
bool: True if signature is valid, False otherwise.
"""
if not CRYPTOGRAPHY_AVAILABLE:
self.logger.error("Cryptography library not available, cannot verify signatures")
return False # Fail verification if library not available (security)
try:
# Extract signature and public key
signature_hex = advert.get('signature', '')
public_key_hex = advert.get('public_key', '')
if not signature_hex or not public_key_hex:
return False
# Convert to bytes
signature_bytes = hex_to_bytes(signature_hex)
public_key_bytes = hex_to_bytes(public_key_hex)
if len(signature_bytes) != 64:
return False
if len(public_key_bytes) != 32:
return False
# The signed data is: pub_key (32) + timestamp (4) + app_data
# Signature covers: pub_key || timestamp || app_data
signed_data = payload[0:32] + payload[32:36] + payload[100:]
# Verify signature
public_key = ed25519.Ed25519PublicKey.from_public_bytes(public_key_bytes)
public_key.verify(signature_bytes, signed_data)
return True
except Exception as e:
if self.verbose:
self.logger.debug(f"Signature verification failed: {e}")
return False
async def _upload_to_map(self, advert: Dict[str, Any], raw_packet_hex: str) -> None:
"""Upload advert to map.meshcore.dev.
Signs the upload request and sends it via HTTP POST.
Args:
advert: Parsed advert data.
raw_packet_hex: Raw hex of the packet to report.
"""
if not self.http_session:
self.logger.error("HTTP session not available")
return
if not self.private_key_hex or not self.public_key_hex:
self.logger.error("Private or public key not available")
return
try:
# Prepare upload data
# Trust the firmware - use raw values directly without conversion
upload_data = {
'params': {
'freq': self.radio_params['freq'],
'cr': self.radio_params['cr'],
'sf': self.radio_params['sf'],
'bw': self.radio_params['bw']
},
'links': [f'meshcore://{raw_packet_hex}']
}
# Log upload data as JSON for debugging
self.logger.debug(f"Upload data (before signing): {json.dumps(upload_data, indent=2)}")
# Sign the data
signed_data = self._sign_data(upload_data)
# Add public key
signed_data['publicKey'] = self.public_key_hex
# Log signed data (without signature for brevity, but show structure)
signed_data_for_log = {
'data': signed_data['data'],
'publicKey': signed_data['publicKey'],
'signature': signed_data['signature'][:32] + '...' + signed_data['signature'][-32:] if len(signed_data['signature']) > 64 else signed_data['signature']
}
self.logger.debug(f"Signed data (for upload): {json.dumps(signed_data_for_log, indent=2)}")
# Log upload
node_info = {
'pubKey': advert.get('public_key', '')[:16] + '...',
'name': advert.get('name', 'unknown'),
'ts': advert.get('advert_time', 0),
'type': advert.get('type', 'unknown')
}
self.logger.info(f"Uploading {node_info}")
# POST to API
async with self.http_session.post(
self.api_url,
json=signed_data,
timeout=aiohttp.ClientTimeout(total=10)
) as response:
try:
result = await response.json()
except Exception as e:
# If response is not JSON, get text
error_text = await response.text()
self.logger.warning(f"Upload failed (status {response.status}): {error_text} (JSON parse error: {e})")
return
# Check for errors in response (API may return 200 with error in JSON)
if response.status == 200:
if 'error' in result:
self.logger.warning(f"Upload failed: {result.get('error', 'Unknown error')} (code: {result.get('code', 'unknown')})")
else:
self.logger.info(f"Upload successful: {result}")
else:
# Handle non-200 status codes
if isinstance(result, dict):
error_text = result.get('error', 'Unknown error')
else:
error_text = str(result) if result else 'Unknown error'
self.logger.warning(f"Upload failed (status {response.status}): {error_text}")
except asyncio.TimeoutError:
self.logger.warning("Upload timeout")
except Exception as e:
self.logger.error(f"Error uploading to map: {e}", exc_info=True)
def _sign_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Sign data using private key.
Args:
data: Dictionary of data to sign.
Returns:
Dict[str, Any]: Object containing original data (JSON string) and signature.
"""
# Convert data to JSON
json_str = json.dumps(data, separators=(',', ':'))
# Hash the JSON
data_hash = hashlib.sha256(json_str.encode('utf-8')).digest()
# Sign the hash
signature_hex = self._sign_hash(data_hash)
return {
'data': json_str,
'signature': signature_hex
}
def _sign_hash(self, data_hash: bytes) -> str:
"""Sign a hash using ed25519 private key (orlp format).
Args:
data_hash: The SHA256 hash of the data to sign.
Returns:
str: Hex string of the signature.
Raises:
ImportError: If PyNaCl is required but missing.
ValueError: If private key length is invalid.
"""
try:
# Convert private key to bytes
private_key_bytes = hex_to_bytes(self.private_key_hex)
public_key_bytes = hex_to_bytes(self.public_key_hex)
# Handle orlp format (64 bytes) vs seed format (32 bytes)
if len(private_key_bytes) == 64:
# Orlp format: scalar (first 32) || prefix (last 32)
# This format requires PyNaCl for proper signing
scalar = private_key_bytes[:32]
prefix = private_key_bytes[32:64]
# Use orlp signing function (same as packet_capture_utils)
# This matches the Node.js supercop implementation
try:
from .packet_capture_utils import ed25519_sign_with_expanded_key
signature = ed25519_sign_with_expanded_key(
data_hash,
scalar,
prefix,
public_key_bytes
)
except ImportError as e:
raise ImportError(
"PyNaCl is required for orlp format signing. "
"Install with: pip install pynacl"
) from e
elif len(private_key_bytes) == 32:
# Seed format: use cryptography library
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(private_key_bytes)
signature = private_key.sign(data_hash)
else:
raise ValueError(f"Invalid private key length: {len(private_key_bytes)}")
# Return as hex
return bytes_to_hex(signature)
except Exception as e:
self.logger.error(f"Error signing data: {e}", exc_info=True)
raise