mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add historical DM decryption
This commit is contained in:
19
CLAUDE.md
19
CLAUDE.md
@@ -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
|
||||
|
||||
|
||||
@@ -291,12 +291,30 @@ if result:
|
||||
|
||||
### Direct Message Decryption
|
||||
|
||||
Direct messages use ECDH key exchange (Ed25519 → X25519). Server-side decryption
|
||||
of direct messages is **not yet implemented**. Currently, direct messages are
|
||||
decrypted by the MeshCore library on the radio itself.
|
||||
Direct messages use ECDH key exchange (Ed25519 → X25519) for shared secret derivation.
|
||||
|
||||
The decoder module contains a `try_decrypt_packet_with_contact_key()` function
|
||||
that could support this feature in the future.
|
||||
**Key storage**: The private key is exported from the radio on startup and stored in memory
|
||||
via `keystore.py`. This enables server-side DM decryption even when contacts aren't loaded
|
||||
on the radio.
|
||||
|
||||
**Real-time decryption**: When a `RAW_DATA` event contains a `TEXT_MESSAGE` packet, the
|
||||
`packet_processor.py` attempts to decrypt it using known contact public keys and the
|
||||
stored private key.
|
||||
|
||||
**Historical decryption**: When creating a contact with `try_historical=True`, the server
|
||||
attempts to decrypt all stored `TEXT_MESSAGE` packets for that contact.
|
||||
|
||||
**Direction detection**: The decoder uses the 1-byte dest_hash and src_hash to determine
|
||||
if a message is incoming or outgoing. Edge case: when both bytes match (1/256 chance),
|
||||
defaults to treating as incoming.
|
||||
|
||||
```python
|
||||
from app.decoder import try_decrypt_dm
|
||||
|
||||
result = try_decrypt_dm(raw_bytes, private_key, contact_public_key)
|
||||
if result:
|
||||
print(f"{result.message} (timestamp={result.timestamp})")
|
||||
```
|
||||
|
||||
## Advertisement Parsing (`decoder.py`)
|
||||
|
||||
@@ -407,6 +425,7 @@ All endpoints are prefixed with `/api`.
|
||||
### Contacts
|
||||
- `GET /api/contacts` - List from database
|
||||
- `GET /api/contacts/{key}` - Get by public key or prefix
|
||||
- `POST /api/contacts` - Create contact (optionally trigger historical DM decryption)
|
||||
- `POST /api/contacts/sync` - Pull from radio to database
|
||||
- `POST /api/contacts/{key}/add-to-radio` - Push to radio
|
||||
- `POST /api/contacts/{key}/remove-from-radio` - Remove from radio
|
||||
|
||||
225
app/decoder.py
225
app/decoder.py
@@ -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
106
app/keystore.py
Normal 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
|
||||
@@ -48,6 +48,11 @@ async def lifespan(app: FastAPI):
|
||||
if radio_manager.meshcore:
|
||||
register_event_handlers(radio_manager.meshcore)
|
||||
|
||||
# Export and store private key for server-side DM decryption
|
||||
from app.keystore import export_and_store_private_key
|
||||
|
||||
await export_and_store_private_key(radio_manager.meshcore)
|
||||
|
||||
# Sync radio clock with system time
|
||||
await sync_radio_time()
|
||||
|
||||
|
||||
@@ -55,6 +55,17 @@ class Contact(BaseModel):
|
||||
}
|
||||
|
||||
|
||||
class CreateContactRequest(BaseModel):
|
||||
"""Request to create a new contact."""
|
||||
|
||||
public_key: str = Field(min_length=64, max_length=64, description="Public key (64-char hex)")
|
||||
name: str | None = Field(default=None, description="Display name for the contact")
|
||||
try_historical: bool = Field(
|
||||
default=False,
|
||||
description="Attempt to decrypt historical DM packets for this contact",
|
||||
)
|
||||
|
||||
|
||||
# Contact type constants
|
||||
CONTACT_TYPE_REPEATER = 2
|
||||
|
||||
|
||||
@@ -17,12 +17,15 @@ import logging
|
||||
import time
|
||||
|
||||
from app.decoder import (
|
||||
DecryptedDirectMessage,
|
||||
PacketInfo,
|
||||
PayloadType,
|
||||
parse_advertisement,
|
||||
parse_packet,
|
||||
try_decrypt_dm,
|
||||
try_decrypt_packet_with_channel_key,
|
||||
)
|
||||
from app.keystore import get_private_key, get_public_key, has_private_key
|
||||
from app.models import CONTACT_TYPE_REPEATER, RawPacketBroadcast, RawPacketDecryptedInfo
|
||||
from app.repository import (
|
||||
ChannelRepository,
|
||||
@@ -161,6 +164,133 @@ async def create_message_from_decrypted(
|
||||
return msg_id
|
||||
|
||||
|
||||
async def create_dm_message_from_decrypted(
|
||||
packet_id: int,
|
||||
decrypted: DecryptedDirectMessage,
|
||||
their_public_key: str,
|
||||
our_public_key: str | None,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
outgoing: bool = False,
|
||||
) -> int | None:
|
||||
"""Create a message record from decrypted direct message packet content.
|
||||
|
||||
This is the shared logic for storing decrypted direct messages,
|
||||
used by both real-time packet processing and historical decryption.
|
||||
|
||||
Args:
|
||||
packet_id: ID of the raw packet being processed
|
||||
decrypted: DecryptedDirectMessage from decoder
|
||||
their_public_key: The contact's full 64-char public key (conversation_key)
|
||||
our_public_key: Our public key (to determine direction), or None
|
||||
received_at: When the packet was received (defaults to now)
|
||||
path: Hex-encoded routing path (None for historical decryption)
|
||||
outgoing: Whether this is an outgoing message (we sent it)
|
||||
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
received = received_at or int(time.time())
|
||||
|
||||
# conversation_key is always the other party's public key
|
||||
conversation_key = their_public_key.lower()
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=decrypted.message,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
path=path,
|
||||
outgoing=outgoing,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
# Duplicate message detected
|
||||
existing_msg = await MessageRepository.get_by_content(
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=decrypted.message,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
)
|
||||
if not existing_msg:
|
||||
logger.warning(
|
||||
"Duplicate DM for contact %s but couldn't find existing",
|
||||
conversation_key[:12],
|
||||
)
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
"Duplicate DM for contact %s (msg_id=%d, outgoing=%s) - adding path",
|
||||
conversation_key[:12],
|
||||
existing_msg.id,
|
||||
existing_msg.outgoing,
|
||||
)
|
||||
|
||||
# Add path if provided
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received)
|
||||
else:
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
# Increment ack count for outgoing messages (echo confirmation)
|
||||
if existing_msg.outgoing:
|
||||
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
|
||||
else:
|
||||
ack_count = await MessageRepository.get_ack_count(existing_msg.id)
|
||||
|
||||
# Broadcast updated paths
|
||||
broadcast_event(
|
||||
"message_acked",
|
||||
{
|
||||
"message_id": existing_msg.id,
|
||||
"ack_count": ack_count,
|
||||
"paths": [p.model_dump() for p in paths] if paths else [],
|
||||
},
|
||||
)
|
||||
|
||||
# Mark this packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Stored direct message %d for contact %s (outgoing=%s)",
|
||||
msg_id,
|
||||
conversation_key[:12],
|
||||
outgoing,
|
||||
)
|
||||
|
||||
# Mark the raw packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
# Build paths array for broadcast
|
||||
paths = [{"path": path or "", "received_at": received}] if path is not None else None
|
||||
|
||||
# Broadcast new message to connected clients
|
||||
broadcast_event(
|
||||
"message",
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "PRIV",
|
||||
"conversation_key": conversation_key,
|
||||
"text": decrypted.message,
|
||||
"sender_timestamp": decrypted.timestamp,
|
||||
"received_at": received,
|
||||
"paths": paths,
|
||||
"txt_type": 0,
|
||||
"signature": None,
|
||||
"outgoing": outgoing,
|
||||
"acked": 0,
|
||||
},
|
||||
)
|
||||
|
||||
# Update contact's last_contacted timestamp (for sorting)
|
||||
await ContactRepository.update_last_contacted(conversation_key, received)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
async def process_raw_packet(
|
||||
raw_bytes: bytes,
|
||||
timestamp: int | None = None,
|
||||
@@ -223,11 +353,11 @@ async def process_raw_packet(
|
||||
# Only process new advertisements (duplicates don't add value)
|
||||
await _process_advertisement(raw_bytes, ts, packet_info)
|
||||
|
||||
# TODO: Add TEXT_MESSAGE (direct message) decryption when private key is available
|
||||
# elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
# decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info)
|
||||
# if decrypt_result:
|
||||
# result.update(decrypt_result)
|
||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
# Try to decrypt direct messages using stored private key and known contacts
|
||||
decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info)
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
# Always broadcast raw packet for the packet feed UI (even duplicates)
|
||||
# This enables the frontend cracker to see all incoming packets in real-time
|
||||
@@ -416,3 +546,123 @@ async def _process_advertisement(
|
||||
from app.radio_sync import sync_recent_contacts_to_radio
|
||||
|
||||
asyncio.create_task(sync_recent_contacts_to_radio())
|
||||
|
||||
|
||||
async def _process_direct_message(
|
||||
raw_bytes: bytes,
|
||||
packet_id: int,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Process a TEXT_MESSAGE (direct message) packet.
|
||||
|
||||
Uses the stored private key and tries to decrypt with known contacts.
|
||||
The src_hash (first byte of sender's public key) is used to narrow down
|
||||
candidate contacts for decryption.
|
||||
"""
|
||||
if not has_private_key():
|
||||
# No private key available - can't decrypt DMs
|
||||
return None
|
||||
|
||||
private_key = get_private_key()
|
||||
our_public_key = get_public_key()
|
||||
if private_key is None or our_public_key is None:
|
||||
return None
|
||||
|
||||
# Parse packet to get the payload for src_hash extraction
|
||||
if packet_info is None:
|
||||
packet_info = parse_packet(raw_bytes)
|
||||
if packet_info is None or packet_info.payload is None:
|
||||
return None
|
||||
|
||||
# Extract src_hash from payload (second byte: [dest_hash:1][src_hash:1][MAC:2][ciphertext])
|
||||
if len(packet_info.payload) < 4:
|
||||
return None
|
||||
|
||||
dest_hash = format(packet_info.payload[0], "02x").lower()
|
||||
src_hash = format(packet_info.payload[1], "02x").lower()
|
||||
|
||||
# Check if this message involves us (either as sender or recipient)
|
||||
our_first_byte = format(our_public_key[0], "02x").lower()
|
||||
|
||||
# Determine direction based on which hash matches us:
|
||||
# - dest_hash == us AND src_hash != us -> incoming (addressed to us from someone else)
|
||||
# - src_hash == us AND dest_hash != us -> outgoing (we sent to someone else)
|
||||
# - Both match us -> ambiguous (our first byte matches contact's), default to incoming
|
||||
# - Neither matches us -> not our message
|
||||
if dest_hash == our_first_byte and src_hash != our_first_byte:
|
||||
is_outgoing = False # Definitely incoming
|
||||
elif src_hash == our_first_byte and dest_hash != our_first_byte:
|
||||
is_outgoing = True # Definitely outgoing
|
||||
elif dest_hash == our_first_byte and src_hash == our_first_byte:
|
||||
# Ambiguous: our first byte matches contact's first byte (1/256 chance)
|
||||
# Default to incoming since dest_hash matching us is more indicative
|
||||
is_outgoing = False
|
||||
logger.debug("Ambiguous DM direction (first bytes match), defaulting to incoming")
|
||||
else:
|
||||
# Neither hash matches us - not our message
|
||||
return None
|
||||
|
||||
# Find candidate contacts based on the relevant hash
|
||||
# For incoming: match src_hash (sender's first byte)
|
||||
# For outgoing: match dest_hash (recipient's first byte)
|
||||
match_hash = dest_hash if is_outgoing else src_hash
|
||||
|
||||
# Get all contacts and filter by first byte of public key
|
||||
contacts = await ContactRepository.get_all(limit=1000)
|
||||
candidate_contacts = [c for c in contacts if c.public_key.lower().startswith(match_hash)]
|
||||
|
||||
if not candidate_contacts:
|
||||
logger.debug(
|
||||
"No contacts found matching hash %s for DM decryption",
|
||||
match_hash,
|
||||
)
|
||||
return None
|
||||
|
||||
# Try decrypting with each candidate contact
|
||||
for contact in candidate_contacts:
|
||||
try:
|
||||
contact_public_key = bytes.fromhex(contact.public_key)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# For incoming messages, pass our_public_key to enable the dest_hash filter
|
||||
# For outgoing messages, skip the filter (dest_hash is the recipient, not us)
|
||||
result = try_decrypt_dm(
|
||||
raw_bytes,
|
||||
private_key,
|
||||
contact_public_key,
|
||||
our_public_key=our_public_key if not is_outgoing else None,
|
||||
)
|
||||
|
||||
if result is not None:
|
||||
# Successfully decrypted!
|
||||
logger.debug(
|
||||
"Decrypted DM %s contact %s: %s",
|
||||
"to" if is_outgoing else "from",
|
||||
contact.name or contact.public_key[:12],
|
||||
result.message[:50] if result.message else "",
|
||||
)
|
||||
|
||||
# Create message (or add path to existing if duplicate)
|
||||
msg_id = await create_dm_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
decrypted=result,
|
||||
their_public_key=contact.public_key,
|
||||
our_public_key=our_public_key.hex(),
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info.path else None,
|
||||
outgoing=is_outgoing,
|
||||
)
|
||||
|
||||
return {
|
||||
"decrypted": True,
|
||||
"contact_name": contact.name,
|
||||
"sender": contact.name or contact.public_key[:12],
|
||||
"message_id": msg_id,
|
||||
}
|
||||
|
||||
# Couldn't decrypt with any known contact
|
||||
logger.debug("Could not decrypt DM with any of %d candidate contacts", len(candidate_contacts))
|
||||
return None
|
||||
|
||||
@@ -231,9 +231,11 @@ class RadioManager:
|
||||
if await self.reconnect():
|
||||
# Re-register event handlers after successful reconnect
|
||||
from app.event_handlers import register_event_handlers
|
||||
from app.keystore import export_and_store_private_key
|
||||
|
||||
if self._meshcore:
|
||||
register_event_handlers(self._meshcore)
|
||||
await export_and_store_private_key(self._meshcore)
|
||||
await self._meshcore.start_auto_message_fetching()
|
||||
logger.info("Event handlers re-registered after auto-reconnect")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
1
frontend/dist/assets/index-C-0BydkT.js.map
vendored
1
frontend/dist/assets/index-C-0BydkT.js.map
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-Ck-mz-Y8.js.map
vendored
Normal file
1
frontend/dist/assets/index-Ck-mz-Y8.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"pydantic-settings>=2.0.0",
|
||||
"aiosqlite>=0.19.0",
|
||||
"pycryptodome>=3.20.0",
|
||||
"pynacl>=1.5.0",
|
||||
"meshcore",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
128
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user