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

View File

@@ -4,6 +4,20 @@
**NEVER make git commits.** A human must make all commits. You may stage files and prepare commit messages, but do not run `git commit`.
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, make sure you run the following and that they all pass green:
```bash
uv run ruff check app/ tests/ --fix # check for python violations
uv run ruff format app/ tests/ # format python
uv run pyright app/ # type check python
PYTHONPATH=. uv run pytest tests/ -v # test python
cd frontend/ # move to frontend directory
npm run lint:fix # fix lint violations
npm run format # format the code
npm run build # run a frontend build
```
## Overview
A web interface for MeshCore mesh radio networks. The backend connects to a MeshCore-compatible radio over serial and exposes REST/WebSocket APIs. The React frontend provides real-time messaging and radio configuration.
@@ -204,6 +218,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
| PUT | `/api/radio/private-key` | Import private key to radio |
| GET | `/api/contacts` | List contacts |
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
| POST | `/api/contacts/sync` | Pull from radio |
| POST | `/api/contacts/{key}/telemetry` | Request telemetry from repeater |
| POST | `/api/contacts/{key}/command` | Send CLI command to repeater |
@@ -265,11 +280,11 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
### Server-Side Decryption
The server can decrypt historical channel packets using stored channel keys.
The server can decrypt packets using stored keys, both in real-time and for historical packets.
**Channel messages**: Decrypted automatically when a matching channel key is available.
**Direct messages**: Currently decrypted only by the MeshCore library on the radio itself. Server-side direct message decryption is not yet implemented.
**Direct messages**: Decrypted server-side using the private key exported from the radio on startup. This enables DM decryption even when the contact isn't loaded on the radio. The private key is stored in memory only (see `keystore.py`).
## MeshCore Library

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

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
app/keystore.py Normal file
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

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()

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

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

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")

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

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."""

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

View File

@@ -137,6 +137,7 @@ await api.sendAdvertisement(true);
// Contacts/Channels
await api.getContacts();
await api.createContact(publicKey, name, tryHistorical); // Create contact, optionally decrypt historical DMs
await api.getChannels();
await api.createChannel('#test');

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-C-0BydkT.js"></script>
<script type="module" crossorigin src="/assets/index-Ck-mz-Y8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CcSjAzwK.css">
</head>
<body>

View File

@@ -467,32 +467,15 @@ export function App() {
// Create contact handler
const handleCreateContact = useCallback(
async (name: string, publicKey: string, tryHistorical: boolean) => {
const newContact: Contact = {
public_key: publicKey,
name,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
};
setContacts((prev) => [...prev, newContact]);
const created = await api.createContact(publicKey, name || undefined, tryHistorical);
const data = await api.getContacts();
setContacts(data);
setActiveConversation({
type: 'contact',
id: publicKey,
name: getContactDisplayName(name, publicKey),
id: created.public_key,
name: getContactDisplayName(created.name, created.public_key),
});
if (tryHistorical) {
console.log('Contact historical decryption not yet supported');
}
},
[]
);

View File

@@ -94,6 +94,11 @@ export const api = {
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',
}),
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
fetchJson<Contact>('/contacts', {
method: 'POST',
body: JSON.stringify({ public_key: publicKey, name, try_historical: tryHistorical }),
}),
markContactRead: (publicKey: string) =>
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
method: 'POST',

View File

@@ -10,6 +10,7 @@ dependencies = [
"pydantic-settings>=2.0.0",
"aiosqlite>=0.19.0",
"pycryptodome>=3.20.0",
"pynacl>=1.5.0",
"meshcore",
]

View File

@@ -10,11 +10,17 @@ import hmac
from Crypto.Cipher import AES
from app.decoder import (
DecryptedDirectMessage,
PayloadType,
RouteType,
_clamp_scalar,
calculate_channel_hash,
decrypt_direct_message,
decrypt_group_text,
derive_public_key,
derive_shared_secret,
parse_packet,
try_decrypt_dm,
try_decrypt_packet_with_channel_key,
)
@@ -355,3 +361,368 @@ class TestAdvertisementParsing:
result = try_parse_advertisement(packet)
assert result is None
class TestScalarClamping:
"""Test X25519 scalar clamping for ECDH."""
def test_clamp_scalar_modifies_first_byte(self):
"""Clamping clears the lower 3 bits of the first byte."""
# Input with all bits set in first byte
scalar = bytes([0xFF]) + bytes(31)
result = _clamp_scalar(scalar)
# First byte should have lower 3 bits cleared: 0xFF & 248 = 0xF8
assert result[0] == 0xF8
def test_clamp_scalar_modifies_last_byte(self):
"""Clamping modifies the last byte for correct group operations."""
# Input with all bits set in last byte
scalar = bytes(31) + bytes([0xFF])
result = _clamp_scalar(scalar)
# Last byte: (0xFF & 63) | 64 = 0x7F
assert result[31] == 0x7F
def test_clamp_scalar_preserves_middle_bytes(self):
"""Clamping preserves the middle bytes unchanged."""
# Known middle bytes
scalar = bytes([0xAB]) + bytes([0x12, 0x34, 0x56] * 10)[:30] + bytes([0xCD])
result = _clamp_scalar(scalar)
# Middle bytes should be unchanged
assert result[1:31] == scalar[1:31]
def test_clamp_scalar_truncates_to_32_bytes(self):
"""Clamping uses only first 32 bytes of input."""
# 64-byte input (typical Ed25519 private key)
scalar = bytes(64)
result = _clamp_scalar(scalar)
assert len(result) == 32
class TestPublicKeyDerivation:
"""Test deriving Ed25519 public key from MeshCore private key."""
# Test data from real MeshCore keys
# The private key's first 32 bytes are the scalar (post-SHA-512 clamped)
# The public key is derived via scalar × basepoint, NOT from the last 32 bytes
#
# IMPORTANT: The last 32 bytes of a MeshCore private key are the signing prefix,
# NOT the public key! Standard Ed25519 libraries will give wrong results because
# they expect a seed, not a raw scalar.
FACE12_PRIV = bytes.fromhex(
"58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B"
"77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1"
)
# Expected public key derived from scalar × basepoint
# Note: This starts with "face12" - the derived public key, NOT the signing prefix
FACE12_PUB_EXPECTED = bytes.fromhex(
"FACE123334789E2B81519AFDBC39A3C9EB7EA3457AD367D3243597A484847E46"
)
def test_derive_public_key_from_meshcore_private(self):
"""derive_public_key correctly derives public key from MeshCore private key."""
result = derive_public_key(self.FACE12_PRIV)
assert len(result) == 32
assert result == self.FACE12_PUB_EXPECTED
def test_derive_public_key_from_scalar_only(self):
"""derive_public_key works with just the 32-byte scalar."""
scalar_only = self.FACE12_PRIV[:32]
result = derive_public_key(scalar_only)
assert len(result) == 32
assert result == self.FACE12_PUB_EXPECTED
def test_derive_public_key_deterministic(self):
"""Same private key always produces same public key."""
result1 = derive_public_key(self.FACE12_PRIV)
result2 = derive_public_key(self.FACE12_PRIV)
assert result1 == result2
class TestSharedSecretDerivation:
"""Test ECDH shared secret derivation from Ed25519 keys."""
# Test data from real MeshCore keys
# The private key's first 32 bytes are the scalar (post-SHA-512 clamped)
# The last 32 bytes are the signing prefix (NOT the public key, though they may match)
FACE12_PRIV = bytes.fromhex(
"58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B"
"77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1"
)
# a1b2c3 public key (32 bytes)
A1B2C3_PUB = bytes.fromhex("a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7")
def test_derive_shared_secret_returns_32_bytes(self):
"""Shared secret derivation returns 32-byte value."""
result = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
assert len(result) == 32
def test_derive_shared_secret_deterministic(self):
"""Same inputs always produce same shared secret."""
result1 = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
result2 = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
assert result1 == result2
def test_derive_shared_secret_different_keys_different_result(self):
"""Different key pairs produce different shared secrets."""
other_pub = bytes.fromhex(
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
)
result1 = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
# This may raise an exception for invalid public key, which is also acceptable
try:
result2 = derive_shared_secret(self.FACE12_PRIV, other_pub)
assert result1 != result2
except Exception:
# Invalid public keys may fail, which is fine
pass
class TestDirectMessageDecryption:
"""Test TEXT_MESSAGE (direct message) payload decryption."""
# Real test vector from user
# Payload: [dest_hash:1][src_hash:1][mac:2][ciphertext]
PAYLOAD = bytes.fromhex(
"FAA1295471ADB44A98B13CA528A4B5C4FBC29B4DA3CED477519B2FBD8FD5467C31E5D58B"
)
# Keys for deriving shared secret
FACE12_PRIV = bytes.fromhex(
"58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B"
"77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1"
)
A1B2C3_PUB = bytes.fromhex("a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7")
EXPECTED_MESSAGE = "Hello there, Mr. Face!"
def test_decrypt_real_dm_payload(self):
"""Decrypt a real DM payload with known shared secret."""
shared_secret = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
result = decrypt_direct_message(self.PAYLOAD, shared_secret)
assert result is not None
assert result.message == self.EXPECTED_MESSAGE
assert result.dest_hash == "fa" # First byte of payload
assert result.src_hash == "a1" # Second byte, matches a1b2c3
def test_decrypt_extracts_timestamp(self):
"""Decrypted message contains valid timestamp."""
shared_secret = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
result = decrypt_direct_message(self.PAYLOAD, shared_secret)
assert result is not None
assert result.timestamp > 0 # Non-zero timestamp
assert result.timestamp < 2**32 # Within uint32 range
def test_decrypt_extracts_flags(self):
"""Decrypted message contains flags byte."""
shared_secret = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
result = decrypt_direct_message(self.PAYLOAD, shared_secret)
assert result is not None
assert isinstance(result.flags, int)
assert 0 <= result.flags <= 255
def test_decrypt_with_wrong_secret_fails(self):
"""Decryption with incorrect shared secret fails MAC verification."""
wrong_secret = bytes(32) # All zeros
result = decrypt_direct_message(self.PAYLOAD, wrong_secret)
assert result is None
def test_decrypt_with_corrupted_mac_fails(self):
"""Corrupted MAC causes decryption to fail."""
shared_secret = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
# Corrupt the MAC (bytes 2-3)
corrupted = self.PAYLOAD[:2] + bytes([0xFF, 0xFF]) + self.PAYLOAD[4:]
result = decrypt_direct_message(corrupted, shared_secret)
assert result is None
def test_decrypt_too_short_payload_returns_none(self):
"""Payloads shorter than minimum (4 bytes) return None."""
shared_secret = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
result = decrypt_direct_message(bytes(3), shared_secret)
assert result is None
def test_decrypt_invalid_ciphertext_length_returns_none(self):
"""Ciphertext not a multiple of 16 bytes returns None."""
shared_secret = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
# 4-byte header + 15-byte ciphertext (not multiple of 16)
invalid_payload = bytes(4 + 15)
result = decrypt_direct_message(invalid_payload, shared_secret)
assert result is None
class TestTryDecryptDM:
"""Test full packet decryption for direct messages."""
# Full packet: header + path_length + payload
# Header byte = 0x09: route_type=FLOOD(1), payload_type=TEXT_MESSAGE(2)
# Header byte = (0 << 6) | (2 << 2) | 1 = 0x09
# Path length = 0
FULL_PACKET = bytes.fromhex(
"0900FAA1295471ADB44A98B13CA528A4B5C4FBC29B4DA3CED477519B2FBD8FD5467C31E5D58B"
)
# Keys
FACE12_PRIV = bytes.fromhex(
"58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B"
"77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1"
)
# FACE12 public key - derived via scalar × basepoint, NOT the last 32 bytes!
# The last 32 bytes (77AC...) are the signing prefix, not the public key.
FACE12_PUB = bytes.fromhex("FACE123334789E2B81519AFDBC39A3C9EB7EA3457AD367D3243597A484847E46")
A1B2C3_PUB = bytes.fromhex("a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7")
EXPECTED_MESSAGE = "Hello there, Mr. Face!"
def test_try_decrypt_dm_full_packet(self):
"""Decrypt a full TEXT_MESSAGE packet."""
result = try_decrypt_dm(
self.FULL_PACKET,
self.FACE12_PRIV,
self.A1B2C3_PUB,
our_public_key=self.FACE12_PUB,
)
assert result is not None
assert result.message == self.EXPECTED_MESSAGE
def test_try_decrypt_dm_inbound_message(self):
"""Decrypt an inbound message (from them to us)."""
# src_hash = a1 matches A1B2C3's first byte
result = try_decrypt_dm(
self.FULL_PACKET,
self.FACE12_PRIV,
self.A1B2C3_PUB,
our_public_key=None, # Without our pubkey, only checks inbound
)
assert result is not None
assert result.src_hash == "a1"
def test_try_decrypt_dm_non_text_message_returns_none(self):
"""Non-TEXT_MESSAGE packets return None."""
# GROUP_TEXT packet (payload_type=5)
# Header byte = (0 << 6) | (5 << 2) | 1 = 0x15
group_text_packet = bytes([0x15, 0x00]) + self.FULL_PACKET[2:]
result = try_decrypt_dm(
group_text_packet,
self.FACE12_PRIV,
self.A1B2C3_PUB,
)
assert result is None
def test_try_decrypt_dm_wrong_src_hash_returns_none(self):
"""Packets from unknown senders return None."""
# Create a packet with different src_hash
# Original: FA A1 ... -> dest=FA, src=A1
# Modified: FA BB ... -> dest=FA, src=BB (doesn't match A1B2C3)
modified_payload = bytes([0xFA, 0xBB]) + self.FULL_PACKET[4:]
modified_packet = self.FULL_PACKET[:2] + modified_payload
result = try_decrypt_dm(
modified_packet,
self.FACE12_PRIV,
self.A1B2C3_PUB,
our_public_key=self.FACE12_PUB,
)
assert result is None
def test_try_decrypt_dm_empty_packet_returns_none(self):
"""Empty packets return None."""
result = try_decrypt_dm(
b"",
self.FACE12_PRIV,
self.A1B2C3_PUB,
)
assert result is None
def test_try_decrypt_dm_truncated_packet_returns_none(self):
"""Truncated packets return None."""
result = try_decrypt_dm(
self.FULL_PACKET[:5], # Only header + partial payload
self.FACE12_PRIV,
self.A1B2C3_PUB,
)
assert result is None
class TestRealWorldDMPacket:
"""End-to-end test with exact real-world test data."""
def test_full_dm_decryption_flow(self):
"""
Complete decryption flow with real test vectors.
Test data from user:
- face12 private key (64 bytes Ed25519)
- a1b2c3 public key (32 bytes)
- Encrypted payload producing "Hello there, Mr. Face!"
"""
# Keys
face12_priv = bytes.fromhex(
"58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B"
"77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1"
)
# Derived public key (scalar × basepoint) - NOT the signing prefix from bytes 32-64
# First byte is 0xFA, matching dest_hash in test packet
face12_pub = bytes.fromhex(
"FACE123334789E2B81519AFDBC39A3C9EB7EA3457AD367D3243597A484847E46"
)
a1b2c3_pub = bytes.fromhex(
"a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7"
)
# Full packet with header
full_packet = bytes.fromhex(
"0900FAA1295471ADB44A98B13CA528A4B5C4FBC29B4DA3CED477519B2FBD8FD5467C31E5D58B"
)
# Decrypt
result = try_decrypt_dm(
full_packet,
face12_priv,
a1b2c3_pub,
our_public_key=face12_pub,
)
# Verify
assert result is not None
assert isinstance(result, DecryptedDirectMessage)
assert result.message == "Hello there, Mr. Face!"
assert result.dest_hash == "fa" # First byte of derived face12 pubkey (0xFA)
assert result.src_hash == "a1" # First byte of a1b2c3 pubkey

View File

@@ -589,3 +589,299 @@ class TestRawPacketStorage:
assert raw_broadcast["decrypted"] is True
assert "decrypted_info" in raw_broadcast
assert raw_broadcast["decrypted_info"]["channel_name"] == fixture["channel_name"]
class TestCreateDMMessageFromDecrypted:
"""Test the DM message creation function for direct message decryption."""
# Test data from real MeshCore DM example
FACE12_PRIV = (
"58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B"
"77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1"
)
# FACE12 public key - derived via scalar × basepoint, NOT the last 32 bytes!
# The last 32 bytes (77AC...) are the signing prefix, not the public key.
FACE12_PUB = "FACE123334789E2B81519AFDBC39A3C9EB7EA3457AD367D3243597A484847E46"
A1B2C3_PUB = "a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7"
@pytest.mark.asyncio
async def test_creates_dm_message_and_broadcasts(self, test_db, captured_broadcasts):
"""create_dm_message_from_decrypted creates message and broadcasts correctly."""
from app.decoder import DecryptedDirectMessage
from app.packet_processor import create_dm_message_from_decrypted
# Create a raw packet first
packet_id, _ = await RawPacketRepository.create(b"test_dm_packet", 1700000000)
# Create a mock decrypted message
decrypted = DecryptedDirectMessage(
timestamp=1700000000,
flags=0,
message="Hello, World!",
dest_hash="fa",
src_hash="a1",
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
msg_id = await create_dm_message_from_decrypted(
packet_id=packet_id,
decrypted=decrypted,
their_public_key=self.A1B2C3_PUB,
our_public_key=self.FACE12_PUB,
received_at=1700000001,
outgoing=False,
)
# Should return a message ID
assert msg_id is not None
assert isinstance(msg_id, int)
# Verify message was stored in database
messages = await MessageRepository.get_all(
msg_type="PRIV", conversation_key=self.A1B2C3_PUB.lower(), limit=10
)
assert len(messages) == 1
assert messages[0].text == "Hello, World!"
assert messages[0].sender_timestamp == 1700000000
assert messages[0].outgoing is False
# Verify broadcast was sent with correct structure
message_broadcasts = [b for b in broadcasts if b["type"] == "message"]
assert len(message_broadcasts) == 1
broadcast = message_broadcasts[0]["data"]
assert broadcast["id"] == msg_id
assert broadcast["type"] == "PRIV"
assert broadcast["conversation_key"] == self.A1B2C3_PUB.lower()
assert broadcast["text"] == "Hello, World!"
assert broadcast["outgoing"] is False
@pytest.mark.asyncio
async def test_handles_outgoing_dm(self, test_db, captured_broadcasts):
"""create_dm_message_from_decrypted handles outgoing messages correctly."""
from app.decoder import DecryptedDirectMessage
from app.packet_processor import create_dm_message_from_decrypted
packet_id, _ = await RawPacketRepository.create(b"test_outgoing_dm", 1700000000)
decrypted = DecryptedDirectMessage(
timestamp=1700000000,
flags=0,
message="My outgoing message",
dest_hash="a1", # Destination is contact (first byte of A1B2C3_PUB)
src_hash="fa", # Source is us (first byte of derived FACE12_PUB)
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
msg_id = await create_dm_message_from_decrypted(
packet_id=packet_id,
decrypted=decrypted,
their_public_key=self.A1B2C3_PUB,
our_public_key=self.FACE12_PUB,
received_at=1700000001,
outgoing=True,
)
assert msg_id is not None
# Verify outgoing flag is set correctly
messages = await MessageRepository.get_all(
msg_type="PRIV", conversation_key=self.A1B2C3_PUB.lower(), limit=10
)
assert len(messages) == 1
assert messages[0].outgoing is True
# Verify broadcast shows outgoing
message_broadcasts = [b for b in broadcasts if b["type"] == "message"]
assert message_broadcasts[0]["data"]["outgoing"] is True
@pytest.mark.asyncio
async def test_returns_none_for_duplicate_dm(self, test_db, captured_broadcasts):
"""create_dm_message_from_decrypted returns None for duplicate DM."""
from app.decoder import DecryptedDirectMessage
from app.packet_processor import create_dm_message_from_decrypted
packet_id_1, _ = await RawPacketRepository.create(b"dm_packet_1", 1700000000)
packet_id_2, _ = await RawPacketRepository.create(b"dm_packet_2", 1700000001)
decrypted = DecryptedDirectMessage(
timestamp=1700000000,
flags=0,
message="Duplicate DM test",
dest_hash="fa",
src_hash="a1",
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
# First call creates the message
msg_id_1 = await create_dm_message_from_decrypted(
packet_id=packet_id_1,
decrypted=decrypted,
their_public_key=self.A1B2C3_PUB,
our_public_key=self.FACE12_PUB,
received_at=1700000001,
outgoing=False,
)
# Second call with same content returns None
msg_id_2 = await create_dm_message_from_decrypted(
packet_id=packet_id_2,
decrypted=decrypted,
their_public_key=self.A1B2C3_PUB,
our_public_key=self.FACE12_PUB,
received_at=1700000002,
outgoing=False,
)
assert msg_id_1 is not None
assert msg_id_2 is None # Duplicate detected
# Only one message broadcast
message_broadcasts = [b for b in broadcasts if b["type"] == "message"]
assert len(message_broadcasts) == 1
@pytest.mark.asyncio
async def test_links_raw_packet_to_dm_message(self, test_db, captured_broadcasts):
"""create_dm_message_from_decrypted links raw packet to message."""
from app.decoder import DecryptedDirectMessage
from app.packet_processor import create_dm_message_from_decrypted
packet_id, _ = await RawPacketRepository.create(b"link_test_dm", 1700000000)
decrypted = DecryptedDirectMessage(
timestamp=1700000000,
flags=0,
message="Link test DM",
dest_hash="fa",
src_hash="a1",
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
await create_dm_message_from_decrypted(
packet_id=packet_id,
decrypted=decrypted,
their_public_key=self.A1B2C3_PUB,
our_public_key=self.FACE12_PUB,
received_at=1700000001,
outgoing=False,
)
# Verify packet is marked decrypted
undecrypted = await RawPacketRepository.get_undecrypted(limit=100)
packet_ids = [p.id for p in undecrypted]
assert packet_id not in packet_ids
@pytest.mark.asyncio
async def test_dm_includes_path_in_broadcast(self, test_db, captured_broadcasts):
"""create_dm_message_from_decrypted includes path in broadcast when provided."""
from app.decoder import DecryptedDirectMessage
from app.packet_processor import create_dm_message_from_decrypted
packet_id, _ = await RawPacketRepository.create(b"path_test_dm", 1700000000)
decrypted = DecryptedDirectMessage(
timestamp=1700000000,
flags=0,
message="Path test DM",
dest_hash="fa",
src_hash="a1",
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
await create_dm_message_from_decrypted(
packet_id=packet_id,
decrypted=decrypted,
their_public_key=self.A1B2C3_PUB,
our_public_key=self.FACE12_PUB,
received_at=1700000001,
path="aabbcc", # Path through 3 repeaters
outgoing=False,
)
message_broadcasts = [b for b in broadcasts if b["type"] == "message"]
assert len(message_broadcasts) == 1
broadcast = message_broadcasts[0]["data"]
assert broadcast["paths"] is not None
assert len(broadcast["paths"]) == 1
assert broadcast["paths"][0]["path"] == "aabbcc"
assert broadcast["paths"][0]["received_at"] == 1700000001
class TestDMDecryptionFunction:
"""Test the DM decryption function with real crypto."""
# Same test data
FACE12_PRIV = bytes.fromhex(
"58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B"
"77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1"
)
# FACE12 public key - derived via scalar × basepoint, NOT the last 32 bytes!
# The last 32 bytes (77AC...) are the signing prefix, not the public key.
FACE12_PUB = bytes.fromhex("FACE123334789E2B81519AFDBC39A3C9EB7EA3457AD367D3243597A484847E46")
A1B2C3_PUB = bytes.fromhex("a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7")
FULL_PACKET = bytes.fromhex(
"0900FAA1295471ADB44A98B13CA528A4B5C4FBC29B4DA3CED477519B2FBD8FD5467C31E5D58B"
)
EXPECTED_MESSAGE = "Hello there, Mr. Face!"
@pytest.mark.asyncio
async def test_full_dm_decryption_pipeline(self, test_db, captured_broadcasts):
"""Test complete DM decryption from raw packet through to stored message."""
from app.decoder import try_decrypt_dm
from app.packet_processor import create_dm_message_from_decrypted
# Store the raw packet
packet_id, _ = await RawPacketRepository.create(self.FULL_PACKET, 1700000000)
# Decrypt the packet
decrypted = try_decrypt_dm(
self.FULL_PACKET,
self.FACE12_PRIV,
self.A1B2C3_PUB,
our_public_key=self.FACE12_PUB,
)
assert decrypted is not None
assert decrypted.message == self.EXPECTED_MESSAGE
# Determine direction (src_hash = a1 matches A1B2C3, so it's inbound)
outgoing = decrypted.src_hash == format(self.FACE12_PUB[0], "02x")
assert outgoing is False # This is an inbound message
broadcasts, mock_broadcast = captured_broadcasts
# Create the message
with patch("app.packet_processor.broadcast_event", mock_broadcast):
msg_id = await create_dm_message_from_decrypted(
packet_id=packet_id,
decrypted=decrypted,
their_public_key=self.A1B2C3_PUB.hex(),
our_public_key=self.FACE12_PUB.hex(),
received_at=1700000000,
outgoing=outgoing,
)
assert msg_id is not None
# Verify the message is stored correctly
messages = await MessageRepository.get_all(
msg_type="PRIV", conversation_key=self.A1B2C3_PUB.hex().lower(), limit=10
)
assert len(messages) == 1
assert messages[0].text == self.EXPECTED_MESSAGE
assert messages[0].outgoing is False
# Verify raw packet is linked
undecrypted = await RawPacketRepository.get_undecrypted(limit=100)
assert packet_id not in [p.id for p in undecrypted]

128
uv.lock generated
View File

@@ -96,6 +96,88 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900 },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283 },
{ url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504 },
{ url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811 },
{ url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402 },
{ url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217 },
{ url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079 },
{ url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475 },
{ url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829 },
{ url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211 },
{ url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036 },
{ url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184 },
{ url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790 },
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 },
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 },
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 },
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 },
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 },
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 },
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 },
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 },
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 },
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 },
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 },
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 },
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 },
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 },
]
[[package]]
name = "click"
version = "8.3.1"
@@ -325,6 +407,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/9a/79e6623e3b65b2ecf3294d3bb14aae74ea529da30d8ff5db1f9cb19c0aac/pycayennelpp-2.4.0-py3-none-any.whl", hash = "sha256:a3e69ea4da9e2971a44d1e275d5555617b2345eea752325f28c0356f64901d62", size = 10417 },
]
[[package]]
name = "pycparser"
version = "2.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140 },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
@@ -516,6 +607,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
]
[[package]]
name = "pynacl"
version = "1.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064 },
{ url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370 },
{ url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304 },
{ url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871 },
{ url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356 },
{ url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814 },
{ url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742 },
{ url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714 },
{ url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257 },
{ url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319 },
{ url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044 },
{ url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740 },
{ url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458 },
{ url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020 },
{ url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174 },
{ url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085 },
{ url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614 },
{ url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251 },
{ url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859 },
{ url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926 },
{ url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101 },
{ url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421 },
{ url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754 },
{ url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801 },
]
[[package]]
name = "pyobjc-core"
version = "12.1"
@@ -736,6 +862,7 @@ dependencies = [
{ name = "meshcore" },
{ name = "pycryptodome" },
{ name = "pydantic-settings" },
{ name = "pynacl" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -763,6 +890,7 @@ requires-dist = [
{ name = "meshcore" },
{ name = "pycryptodome", specifier = ">=3.20.0" },
{ name = "pydantic-settings", specifier = ">=2.0.0" },
{ name = "pynacl", specifier = ">=1.5.0" },
{ name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.24.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" },