absolute annurism, pls never use surrealdb with solid.js if you want thinsg to work :) this si the very last time I'll ever touch solid.js and surrealdb, thanks..

This commit is contained in:
CarterPerez-dev 2026-01-01 00:53:38 -05:00
parent 7b061955e0
commit de7d8ea8e6
34 changed files with 3007 additions and 486 deletions

View File

@ -39,7 +39,7 @@ REDIS_URL=redis://redis:6379
# WebAuthn / Passkeys # WebAuthn / Passkeys
RP_ID=localhost RP_ID=localhost
RP_NAME=Encrypted P2P Chat RP_NAME="Encrypted P2P Chat"
RP_ORIGIN=http://localhost RP_ORIGIN=http://localhost
# Frontend (Vite requires VITE_ prefix) # Frontend (Vite requires VITE_ prefix)
@ -48,7 +48,7 @@ VITE_WS_URL=ws://localhost:8000
VITE_RP_ID=localhost VITE_RP_ID=localhost
# CORS # CORS
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"] CORS_ORIGINS='["http://localhost:3000","http://localhost:5173"]'
# WebSocket # WebSocket
WS_HEARTBEAT_INTERVAL=30 WS_HEARTBEAT_INTERVAL=30

View File

@ -1,110 +0,0 @@
# ⒸAngelaMos | 2025
# Makefile
.PHONY: help setup setup-backend setup-frontend env dev prod build-dev build-prod up-dev up-prod down-dev down-prod logs-dev logs-prod test-backend clean
help:
@echo "Encrypted P2P Chat - Makefile Commands"
@echo ""
@echo "Setup:"
@echo " make setup - Complete project setup (backend + frontend)"
@echo " make setup-backend - Setup backend (venv, install deps)"
@echo " make setup-frontend - Setup frontend (install npm deps)"
@echo " make env - Copy .env.example to .env files"
@echo ""
@echo "Development:"
@echo " make dev - Start development environment"
@echo " make build-dev - Build development Docker images"
@echo " make up-dev - Start development containers"
@echo " make down-dev - Stop development containers"
@echo " make logs-dev - Follow development logs"
@echo ""
@echo "Production:"
@echo " make prod - Start production environment"
@echo " make build-prod - Build production Docker images"
@echo " make up-prod - Start production containers"
@echo " make down-prod - Stop production containers"
@echo " make logs-prod - Follow production logs"
@echo ""
@echo "Testing:"
@echo " make test-backend - Run backend tests"
@echo ""
@echo "Cleanup:"
@echo " make clean - Remove all containers, volumes, and build artifacts"
setup: setup-backend setup-frontend env
@echo "Setup complete!"
setup-backend:
@echo "Setting up backend..."
cd backend && python3 -m venv ../.venv
. .venv/bin/activate && cd backend && pip install -e .[dev]
@echo "Backend setup complete!"
setup-frontend:
@echo "Setting up frontend..."
cd frontend && npm install
@echo "Frontend setup complete!"
env:
@echo "Creating .env files..."
@if [ ! -f .env ]; then cp .env.example .env; echo "Created root .env"; fi
@if [ ! -f frontend/.env ]; then cp frontend/.env.example frontend/.env; echo "Created frontend/.env"; fi
@echo ".env files created! Please update with your values."
dev: build-dev up-dev
build-dev:
@echo "Building development Docker images..."
docker compose -f docker-compose.dev.yml build
up-dev:
@echo "Starting development environment..."
docker compose -f docker-compose.dev.yml up -d
@echo "Development environment started!"
@echo "Frontend: http://localhost:5173"
@echo "Backend: http://localhost:8000"
@echo "Nginx: http://localhost"
down-dev:
@echo "Stopping development environment..."
docker compose -f docker-compose.dev.yml down
logs-dev:
docker compose -f docker-compose.dev.yml logs -f
prod: build-prod up-prod
build-prod:
@echo "Building production Docker images..."
docker compose -f docker-compose.prod.yml build
up-prod:
@echo "Starting production environment..."
docker compose -f docker-compose.prod.yml up -d
@echo "Production environment started!"
@echo "Application: http://localhost"
down-prod:
@echo "Stopping production environment..."
docker compose -f docker-compose.prod.yml down
logs-prod:
docker compose -f docker-compose.prod.yml logs -f
test-backend:
@echo "Running backend tests..."
. .venv/bin/activate && cd backend && python -m pytest tests/ -v
clean:
@echo "Cleaning up..."
docker compose -f docker-compose.dev.yml down -v
docker compose -f docker-compose.prod.yml down -v
rm -rf frontend/node_modules
rm -rf frontend/dist
rm -rf backend/.venv
rm -rf backend/__pycache__
rm -rf backend/.pytest_cache
rm -rf backend/.mypy_cache
rm -rf backend/.ruff_cache
@echo "Cleanup complete!"

View File

@ -31,6 +31,7 @@ End-to-end encrypted P2P chat application with Signal Protocol (Double Ratchet +
- Docker and Docker Compose - Docker and Docker Compose
- **Node.js 20.19+ or 22.12+** (required for Vite 7) - **Node.js 20.19+ or 22.12+** (required for Vite 7)
- **Python 3.13+** (latest stable) - **Python 3.13+** (latest stable)
- **uv** (Python package manager) - `curl -LsSf https://astral.sh/uv/install.sh | sh`
- Make - Make
### Setup ### Setup
@ -161,10 +162,9 @@ encrypted-p2p-chat/
```bash ```bash
cd backend cd backend
python -m venv ../.venv uv venv ../.venv
source ../.venv/bin/activate uv pip install -e .[dev]
pip install -e .[dev] uv run pytest tests/ -v
python -m pytest tests/ -v
``` ```
### Frontend Development ### Frontend Development
@ -189,7 +189,7 @@ Or manually:
```bash ```bash
cd backend cd backend
python -m pytest tests/ -v uv run pytest tests/ -v
``` ```
## Environment Variables ## Environment Variables

View File

@ -4,9 +4,11 @@ Encryption endpoints for X3DH prekey bundles
""" """
import logging import logging
from typing import Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.Base import get_session from app.models.Base import get_session
@ -19,6 +21,14 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix = "/encryption", tags = ["encryption"]) router = APIRouter(prefix = "/encryption", tags = ["encryption"])
class ClientKeysUpload(BaseModel):
identity_key: str
identity_key_ed25519: str
signed_prekey: str
signed_prekey_signature: str
one_time_prekeys: list[str]
@router.get( @router.get(
"/prekey-bundle/{user_id}", "/prekey-bundle/{user_id}",
status_code = status.HTTP_200_OK, status_code = status.HTTP_200_OK,
@ -48,6 +58,7 @@ async def initialize_keys(
) -> dict[str, ) -> dict[str,
str]: str]:
""" """
[DEPRECATED] Server-side key generation - kept for backwards compatibility
Initializes encryption keys for a user Initializes encryption keys for a user
""" """
await prekey_service.initialize_user_keys(session, user_id) await prekey_service.initialize_user_keys(session, user_id)
@ -58,6 +69,32 @@ async def initialize_keys(
} }
@router.post("/upload-keys/{user_id}", status_code = status.HTTP_201_CREATED)
async def upload_client_keys(
user_id: UUID,
keys: ClientKeysUpload,
session: AsyncSession = Depends(get_session),
) -> dict[str,
str]:
"""
Stores client-generated public keys for E2E encryption
"""
await prekey_service.store_client_keys(
session,
user_id,
keys.identity_key,
keys.identity_key_ed25519,
keys.signed_prekey,
keys.signed_prekey_signature,
keys.one_time_prekeys
)
return {
"status": "success",
"message": f"Stored client keys for user {user_id}"
}
@router.post("/rotate-signed-prekey/{user_id}", status_code = status.HTTP_200_OK) @router.post("/rotate-signed-prekey/{user_id}", status_code = status.HTTP_200_OK)
async def rotate_signed_prekey( async def rotate_signed_prekey(
user_id: UUID, user_id: UUID,

View File

@ -140,8 +140,6 @@ async def list_rooms(
""" """
List all rooms for the specified user List all rooms for the specified user
""" """
logger.info("list_rooms called for user_id: %s", user_id)
user = await auth_service.get_user_by_id(session, UUID(user_id)) user = await auth_service.get_user_by_id(session, UUID(user_id))
if not user: if not user:
@ -151,8 +149,6 @@ async def list_rooms(
) )
room_data_list = await surreal_db.get_rooms_for_user(user_id) room_data_list = await surreal_db.get_rooms_for_user(user_id)
logger.info("SurrealDB returned %d rooms for user %s", len(room_data_list), user_id)
logger.info("Room data: %s", room_data_list)
rooms: list[RoomAPIResponse] = [] rooms: list[RoomAPIResponse] = []

View File

@ -36,6 +36,7 @@ class PreKeyBundle:
Recipient prekey bundle for X3DH protocol Recipient prekey bundle for X3DH protocol
""" """
identity_key: str identity_key: str
identity_key_ed25519: str
signed_prekey: str signed_prekey: str
signed_prekey_signature: str signed_prekey_signature: str
one_time_prekey: str | None = None one_time_prekey: str | None = None

View File

@ -44,13 +44,6 @@ class SurrealDBManager:
if self._connected: if self._connected:
return return
logger.warning(
"SurrealDB connecting: URL=%s, NS=%s, DB=%s",
settings.SURREAL_URL,
settings.SURREAL_NAMESPACE,
settings.SURREAL_DATABASE,
)
self.db = AsyncSurreal(settings.SURREAL_URL) self.db = AsyncSurreal(settings.SURREAL_URL)
await self.db.connect() await self.db.connect()
@ -67,11 +60,6 @@ class SurrealDBManager:
) )
self._connected = True self._connected = True
logger.warning(
"SurrealDB connected successfully to %s/%s",
settings.SURREAL_NAMESPACE,
settings.SURREAL_DATABASE,
)
async def disconnect(self) -> None: async def disconnect(self) -> None:
""" """
@ -87,31 +75,8 @@ class SurrealDBManager:
Ensure connection is established Ensure connection is established
""" """
if not self._connected: if not self._connected:
logger.warning("ensure_connected: Connection was lost, reconnecting...")
await self.connect() await self.connect()
else:
logger.warning(
"ensure_connected: Already connected (id=%s)",
id(self.db)
)
async def _debug_connection_state(self) -> dict[str, Any]:
"""
Debug helper to check current connection state
"""
try:
info_result = await self.db.query("INFO FOR DB")
rooms_count = await self.db.query("SELECT count() FROM rooms GROUP ALL")
participants_count = await self.db.query(
"SELECT count() FROM room_participants GROUP ALL"
)
return {
"db_info": info_result,
"rooms_count": rooms_count,
"participants_count": participants_count,
}
except Exception as e:
return {"error": str(e)}
def _extract_query_result(self, result: Any) -> list[dict[str, Any]]: def _extract_query_result(self, result: Any) -> list[dict[str, Any]]:
""" """
@ -146,16 +111,13 @@ class SurrealDBManager:
async def create_message( async def create_message(
self, self,
message_data: dict[str, message_data: dict[str, Any]
Any]
) -> MessageResponse: ) -> MessageResponse:
""" """
Create a new message in SurrealDB Create a new message in SurrealDB
""" """
await self.ensure_connected() await self.ensure_connected()
logger.warning("create_message - data: %s", message_data)
result = await self.db.create("messages", message_data) result = await self.db.create("messages", message_data)
logger.warning("create_message - result: %s", result)
result["id"] = str(result["id"]) result["id"] = str(result["id"])
return MessageResponse(**result) return MessageResponse(**result)
@ -169,10 +131,6 @@ class SurrealDBManager:
Get messages for a specific room with pagination Get messages for a specific room with pagination
""" """
await self.ensure_connected() await self.ensure_connected()
logger.warning("get_room_messages - room_id: %s", room_id)
all_messages = await self.db.query("SELECT * FROM messages")
logger.warning("get_room_messages - ALL messages in DB: %s", all_messages)
query = """ query = """
SELECT * FROM messages SELECT * FROM messages
@ -189,9 +147,9 @@ class SurrealDBManager:
"offset": offset, "offset": offset,
} }
) )
logger.warning("get_room_messages - RAW result: %s", result)
messages = self._extract_query_result(result) messages = self._extract_query_result(result)
logger.warning("get_room_messages - extracted messages: %s", messages) for msg in messages:
msg["id"] = str(msg["id"])
return [MessageResponse(**msg) for msg in messages] return [MessageResponse(**msg) for msg in messages]
async def create_room(self, room_data: dict[str, Any]) -> RoomResponse: async def create_room(self, room_data: dict[str, Any]) -> RoomResponse:
@ -200,15 +158,11 @@ class SurrealDBManager:
""" """
await self.ensure_connected() await self.ensure_connected()
query = "CREATE rooms CONTENT $data" query = "CREATE rooms CONTENT $data"
logger.warning("create_room - executing query with data: %s", room_data)
result = await self.db.query(query, {"data": room_data}) result = await self.db.query(query, {"data": room_data})
logger.warning("create_room - RAW result type: %s", type(result))
logger.warning("create_room - RAW result: %s", result)
if isinstance(result, list) and len(result) > 0: if isinstance(result, list) and len(result) > 0:
first = result[0] first = result[0]
logger.info("create_room - first element type: %s, value: %s", type(first), first)
if isinstance(first, dict): if isinstance(first, dict):
if "result" in first and first["result"]: if "result" in first and first["result"]:
@ -216,19 +170,15 @@ class SurrealDBManager:
elif "id" in first: elif "id" in first:
room = first room = first
else: else:
logger.error("create_room - unexpected dict structure: %s", first)
raise ValueError(f"Unexpected result structure: {first}") raise ValueError(f"Unexpected result structure: {first}")
elif isinstance(first, list) and len(first) > 0: elif isinstance(first, list) and len(first) > 0:
room = first[0] room = first[0]
else: else:
logger.error("create_room - unexpected first element: %s", first)
raise ValueError(f"Unexpected result: {first}") raise ValueError(f"Unexpected result: {first}")
room["id"] = str(room["id"]) room["id"] = str(room["id"])
logger.info("create_room - final room: %s", room)
return RoomResponse(**room) return RoomResponse(**room)
logger.error("create_room - no results returned: %s", result)
raise ValueError(f"Failed to create room, result: {result}") raise ValueError(f"Failed to create room, result: {result}")
async def add_room_participant( async def add_room_participant(
@ -238,10 +188,21 @@ class SurrealDBManager:
role: str = "member", role: str = "member",
) -> None: ) -> None:
""" """
Add a participant to a room Add a participant to a room (skips if already exists)
""" """
await self.ensure_connected() await self.ensure_connected()
check_query = """
SELECT * FROM room_participants
WHERE room_id = $room_id AND user_id = $user_id
"""
existing = await self.db.query(check_query, {"room_id": room_id, "user_id": user_id})
existing_data = self._extract_query_result(existing)
if existing_data:
logger.info("Participant %s already in room %s, skipping", user_id, room_id)
return
query = """ query = """
CREATE room_participants CONTENT { CREATE room_participants CONTENT {
room_id: $room_id, room_id: $room_id,
@ -256,16 +217,8 @@ class SurrealDBManager:
"role": role, "role": role,
"joined_at": datetime.now(UTC).isoformat(), "joined_at": datetime.now(UTC).isoformat(),
} }
logger.warning("add_room_participant - params: %s", params)
result = await self.db.query(query, params) await self.db.query(query, params)
logger.warning("add_room_participant result: %s", result)
verify = await self.db.query("SELECT * FROM room_participants")
logger.warning("VERIFY ALL room_participants: %s", verify)
debug_state = await self._debug_connection_state()
logger.warning("add_room_participant - DB STATE after insert: %s", debug_state)
async def get_rooms_for_user(self, user_id: str) -> list[dict[str, Any]]: async def get_rooms_for_user(self, user_id: str) -> list[dict[str, Any]]:
""" """
@ -273,27 +226,18 @@ class SurrealDBManager:
""" """
await self.ensure_connected() await self.ensure_connected()
debug_state = await self._debug_connection_state()
logger.warning("get_rooms_for_user - DB STATE: %s", debug_state)
participants_query = """ participants_query = """
SELECT room_id FROM room_participants WHERE user_id = $user_id SELECT room_id FROM room_participants WHERE user_id = $user_id
""" """
logger.warning("get_rooms_for_user - fetching participants for user_id: %s", user_id)
result = await self.db.query(participants_query, {"user_id": user_id}) result = await self.db.query(participants_query, {"user_id": user_id})
logger.warning("get_rooms_for_user - RAW result: %s", result)
participant_data = self._extract_query_result(result) participant_data = self._extract_query_result(result)
logger.warning("Participant data after extract: %s", participant_data)
if not participant_data: if not participant_data:
logger.warning("No room_participants found for user %s", user_id)
return [] return []
room_ids = [p["room_id"] for p in participant_data if p.get("room_id")] room_ids = list({p["room_id"] for p in participant_data if p.get("room_id")})
logger.info("Room IDs to fetch: %s", room_ids)
if not room_ids: if not room_ids:
logger.warning("No valid room_ids for user %s", user_id)
return [] return []
rooms: list[dict[str, Any]] = [] rooms: list[dict[str, Any]] = []
@ -305,10 +249,9 @@ class SurrealDBManager:
rooms.extend(room) rooms.extend(room)
else: else:
rooms.append(room) rooms.append(room)
except Exception as e: except Exception:
logger.warning("Failed to fetch room %s: %s", room_id, e) pass
logger.info("Fetched %d rooms", len(rooms))
return rooms return rooms
async def get_room_participants(self, room_id: str) -> list[dict[str, Any]]: async def get_room_participants(self, room_id: str) -> list[dict[str, Any]]:
@ -335,7 +278,10 @@ class SurrealDBManager:
result = await self.db.query(query, {"user_id": f"users:`{user_id}`"}) result = await self.db.query(query, {"user_id": f"users:`{user_id}`"})
data = self._extract_query_result(result) data = self._extract_query_result(result)
if data and isinstance(data[0], dict) and data[0].get("rooms"): if data and isinstance(data[0], dict) and data[0].get("rooms"):
return [RoomResponse(**room) for room in data[0]["rooms"]] rooms = data[0]["rooms"]
for room in rooms:
room["id"] = str(room["id"])
return [RoomResponse(**room) for room in rooms]
return [] return []
async def update_presence( async def update_presence(

View File

@ -68,6 +68,7 @@ def create_app() -> FastAPI:
title = settings.APP_NAME, title = settings.APP_NAME,
description = APP_DESCRIPTION, description = APP_DESCRIPTION,
version = APP_VERSION, version = APP_VERSION,
openapi_version = "3.1.0",
docs_url = "/docs" if settings.is_development else None, docs_url = "/docs" if settings.is_development else None,
redoc_url = "/redoc" if settings.is_development else None, redoc_url = "/redoc" if settings.is_development else None,
default_response_class = ORJSONResponse, default_response_class = ORJSONResponse,

View File

@ -266,6 +266,53 @@ class MessageService:
logger.error("Database error saving ratchet state: %s", e) logger.error("Database error saving ratchet state: %s", e)
raise DatabaseError("Failed to save ratchet state") from e raise DatabaseError("Failed to save ratchet state") from e
async def store_encrypted_message(
self,
session: AsyncSession,
sender_id: UUID,
recipient_id: UUID,
ciphertext: str,
nonce: str,
header: str,
room_id: str | None = None,
) -> Any:
"""
Stores client-encrypted message in SurrealDB (pass-through, no server encryption)
"""
sender_user_statement = select(User).where(User.id == sender_id)
sender_user_result = await session.execute(sender_user_statement)
sender_user = sender_user_result.scalar_one_or_none()
if not sender_user:
raise UserNotFoundError("Sender not found")
from datetime import UTC, datetime
now = datetime.now(UTC)
surreal_message = {
"sender_id": str(sender_id),
"recipient_id": str(recipient_id),
"room_id": room_id,
"ciphertext": ciphertext,
"nonce": nonce,
"header": header,
"sender_username": sender_user.username,
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
}
try:
result = await surreal_db.create_message(surreal_message)
logger.info(
"Stored client-encrypted message: %s -> %s",
sender_id,
recipient_id
)
return result
except Exception as e:
logger.error("Failed to store encrypted message: %s", e)
raise DatabaseError(f"Failed to store message: {str(e)}") from e
async def send_encrypted_message( async def send_encrypted_message(
self, self,
session: AsyncSession, session: AsyncSession,
@ -275,6 +322,7 @@ class MessageService:
room_id: str | None = None, room_id: str | None = None,
) -> Any: ) -> Any:
""" """
[DEPRECATED] Server-side encryption - kept for backwards compatibility
Encrypts message with Double Ratchet and stores in SurrealDB Encrypts message with Double Ratchet and stores in SurrealDB
""" """
ratchet_state_statement = select(RatchetState).where( ratchet_state_statement = select(RatchetState).where(

View File

@ -42,6 +42,113 @@ class PrekeyService:
""" """
Service for managing X3DH prekey bundles and key rotation Service for managing X3DH prekey bundles and key rotation
""" """
async def store_client_keys(
self,
session: AsyncSession,
user_id: UUID,
identity_key: str,
identity_key_ed25519: str,
signed_prekey: str,
signed_prekey_signature: str,
one_time_prekeys: list[str]
) -> IdentityKey:
"""
Stores client-generated public keys for E2E encryption.
Only stores PUBLIC keys - private keys remain on client.
"""
statement = select(User).where(User.id == user_id)
result = await session.execute(statement)
user = result.scalar_one_or_none()
if not user:
logger.error("User not found: %s", user_id)
raise UserNotFoundError("User not found")
existing_ik_statement = select(IdentityKey).where(
IdentityKey.user_id == user_id
)
existing_ik_result = await session.execute(existing_ik_statement)
existing_ik = existing_ik_result.scalar_one_or_none()
if existing_ik:
existing_ik.public_key = identity_key
existing_ik.public_key_ed25519 = identity_key_ed25519
logger.info("Updated existing identity key for user %s", user_id)
else:
existing_ik = IdentityKey(
user_id = user_id,
public_key = identity_key,
private_key = "",
public_key_ed25519 = identity_key_ed25519,
private_key_ed25519 = ""
)
session.add(existing_ik)
logger.info("Created identity key for user %s", user_id)
old_spks_statement = select(SignedPrekey).where(
SignedPrekey.user_id == user_id,
SignedPrekey.is_active
)
old_spks_result = await session.execute(old_spks_statement)
old_spks = old_spks_result.scalars().all()
for old_spk in old_spks:
old_spk.is_active = False
max_key_id_statement = select(SignedPrekey.key_id).where(
SignedPrekey.user_id == user_id
).order_by(SignedPrekey.key_id.desc()).limit(1)
max_key_id_result = await session.execute(max_key_id_statement)
max_key_id = max_key_id_result.scalar_one_or_none()
new_spk_key_id = (max_key_id + 1) if max_key_id is not None else 1
expires_at = datetime.now(UTC) + timedelta(
hours = SIGNED_PREKEY_ROTATION_HOURS
)
new_spk = SignedPrekey(
user_id = user_id,
key_id = new_spk_key_id,
public_key = signed_prekey,
private_key = "",
signature = signed_prekey_signature,
is_active = True,
expires_at = expires_at
)
session.add(new_spk)
max_opk_key_id_statement = select(OneTimePrekey.key_id).where(
OneTimePrekey.user_id == user_id
).order_by(OneTimePrekey.key_id.desc()).limit(1)
max_opk_key_id_result = await session.execute(max_opk_key_id_statement)
max_opk_key_id = max_opk_key_id_result.scalar_one_or_none()
next_opk_key_id = (max_opk_key_id + 1) if max_opk_key_id is not None else 1
for i, opk_public in enumerate(one_time_prekeys):
new_opk = OneTimePrekey(
user_id = user_id,
key_id = next_opk_key_id + i,
public_key = opk_public,
private_key = "",
is_used = False
)
session.add(new_opk)
try:
await session.commit()
await session.refresh(existing_ik)
logger.info(
"Stored client keys for user %s: IK + SPK + %s OPKs",
user_id,
len(one_time_prekeys)
)
except IntegrityError as e:
await session.rollback()
logger.error("Database error storing client keys: %s", e)
raise DatabaseError("Failed to store client keys") from e
return existing_ik
async def initialize_user_keys( async def initialize_user_keys(
self, self,
session: AsyncSession, session: AsyncSession,
@ -239,6 +346,7 @@ class PrekeyService:
bundle = PreKeyBundle( bundle = PreKeyBundle(
identity_key = identity_key.public_key, identity_key = identity_key.public_key,
identity_key_ed25519 = identity_key.public_key_ed25519,
signed_prekey = signed_prekey.public_key, signed_prekey = signed_prekey.public_key,
signed_prekey_signature = signed_prekey.signature, signed_prekey_signature = signed_prekey.signature,
one_time_prekey = one_time_prekey_public one_time_prekey = one_time_prekey_public

View File

@ -91,16 +91,18 @@ class WebSocketService:
Any] Any]
) -> None: ) -> None:
""" """
Process encrypted message from client and forward to recipient Process client-encrypted message and forward to recipient (pass-through)
""" """
try: try:
recipient_id = UUID(message.get("recipient_id")) recipient_id = UUID(message.get("recipient_id"))
room_id = message.get("room_id") room_id = message.get("room_id")
plaintext = message.get("plaintext") ciphertext = message.get("ciphertext")
nonce = message.get("nonce")
header = message.get("header")
temp_id = message.get("temp_id", "") temp_id = message.get("temp_id", "")
if not plaintext: if not ciphertext or not nonce or not header:
logger.error("Missing plaintext in message from %s", user_id) logger.error("Missing encryption fields in message from %s", user_id)
return return
if not room_id: if not room_id:
@ -108,11 +110,13 @@ class WebSocketService:
return return
async with async_session_maker() as session: async with async_session_maker() as session:
result = await message_service.send_encrypted_message( result = await message_service.store_encrypted_message(
session, session,
user_id, user_id,
recipient_id, recipient_id,
plaintext, ciphertext,
nonce,
header,
room_id, room_id,
) )
@ -121,15 +125,15 @@ class WebSocketService:
sender_id = str(user_id), sender_id = str(user_id),
recipient_id = str(recipient_id), recipient_id = str(recipient_id),
room_id = room_id, room_id = room_id,
content = plaintext, content = "",
ciphertext = result.ciphertext if hasattr(result, 'ciphertext') else "", ciphertext = ciphertext,
nonce = result.nonce if hasattr(result, 'nonce') else "", nonce = nonce,
header = result.header if hasattr(result, 'header') else "", header = header,
sender_username = result.sender_username if hasattr(result, 'sender_username') else "" sender_username = result.sender_username if hasattr(result, 'sender_username') else ""
) )
is_recipient_connected = connection_manager.is_user_connected(recipient_id) is_recipient_connected = connection_manager.is_user_connected(recipient_id)
logger.warning( logger.debug(
"Sending to recipient %s - connected: %s", "Sending to recipient %s - connected: %s",
recipient_id, recipient_id,
is_recipient_connected is_recipient_connected
@ -139,7 +143,7 @@ class WebSocketService:
recipient_id, recipient_id,
ws_message.model_dump(mode = "json") ws_message.model_dump(mode = "json")
) )
logger.warning("Message sent to recipient %s", recipient_id) logger.debug("Message sent to recipient %s", recipient_id)
confirmation = MessageSentWS( confirmation = MessageSentWS(
temp_id = temp_id, temp_id = temp_id,

File diff suppressed because it is too large Load Diff

View File

@ -5,12 +5,12 @@
FROM python:3.13-slim FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
build-essential \ build-essential \
@ -19,7 +19,7 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY backend/pyproject.toml ./ COPY backend/pyproject.toml ./
RUN pip install -e .[dev] RUN uv pip install --system -e .[dev]
COPY backend/ . COPY backend/ .

View File

@ -5,12 +5,12 @@
FROM python:3.13-slim AS builder FROM python:3.13-slim AS builder
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
build-essential \ build-essential \
@ -18,8 +18,7 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY backend/pyproject.toml ./ COPY backend/pyproject.toml ./
RUN pip install --user --no-warn-script-location . RUN uv pip install --target /app/deps . gunicorn
RUN pip install --user --no-warn-script-location gunicorn
FROM python:3.13-slim FROM python:3.13-slim
@ -32,8 +31,9 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
useradd -m -u 1000 appuser useradd -m -u 1000 appuser
COPY --from=builder /root/.local /home/appuser/.local COPY --from=builder /app/deps /home/appuser/.local/lib/python3.13/site-packages
ENV PATH=/home/appuser/.local/bin:$PATH ENV PYTHONPATH=/home/appuser/.local/lib/python3.13/site-packages:$PYTHONPATH \
PATH=/home/appuser/.local/lib/python3.13/site-packages/bin:$PATH
COPY backend/ . COPY backend/ .

View File

@ -17,23 +17,23 @@ interface MessageBubbleProps {
export function MessageBubble(props: MessageBubbleProps): JSX.Element { export function MessageBubble(props: MessageBubbleProps): JSX.Element {
const bubbleClasses = (): string => { const bubbleClasses = (): string => {
const base = "max-w-[80%] p-3" const base = "max-w-[70%] p-4"
if (props.isOwnMessage) { if (props.isOwnMessage) {
return `${base} bg-orange text-black ml-auto` return `${base} bg-orange text-black`
} }
return `${base} bg-black border-2 border-orange text-white` return `${base} bg-black border-2 border-orange text-white`
} }
return ( return (
<div class={`flex ${props.isOwnMessage ? "justify-end" : "justify-start"} ${props.class ?? ""}`}> <div class={`flex px-4 ${props.isOwnMessage ? "justify-end" : "justify-start"} ${props.class ?? ""}`}>
<div class={bubbleClasses()}> <div class={bubbleClasses()}>
<Show when={props.showSender === true && !props.isOwnMessage}> <Show when={props.showSender === true && !props.isOwnMessage}>
<div class="font-pixel text-[8px] text-orange mb-1"> <div class="font-pixel text-[10px] text-orange mb-2">
{props.message.sender_username} {props.message.sender_username}
</div> </div>
</Show> </Show>
<div class="font-pixel text-[10px] break-words whitespace-pre-wrap"> <div class="font-placeholder break-words whitespace-pre-wrap" style="font-size: 32px; line-height: 1.4;">
{props.message.content} {props.message.content}
</div> </div>

View File

@ -1,5 +1,5 @@
/** /**
* Main application shell with sidebar and content area * Main application shell layout
*/ */
import type { ParentProps, JSX } from "solid-js" import type { ParentProps, JSX } from "solid-js"
@ -21,59 +21,30 @@ export function AppShell(props: AppShellProps): JSX.Element {
const showSidebar = (): boolean => props.showSidebar ?? true const showSidebar = (): boolean => props.showSidebar ?? true
const showHeader = (): boolean => props.showHeader ?? true const showHeader = (): boolean => props.showHeader ?? true
const getSidebarClasses = (): string => {
if (isMobile()) {
return sidebarOpen() ? "fixed inset-y-0 left-0 z-40 w-72" : "hidden"
}
return sidebarOpen() ? "w-72" : "w-0 overflow-hidden"
}
const handleBackdropClick = (): void => {
$sidebarOpen.set(false)
}
const handleBackdropKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
e.preventDefault()
$sidebarOpen.set(false)
}
}
return ( return (
<div class="h-screen flex flex-col bg-black overflow-hidden"> <div class="flex flex-col h-screen w-screen overflow-hidden bg-black">
<Show when={showHeader()}> <Show when={showHeader()}>
<Header /> <Header />
</Show> </Show>
<div class="flex-1 flex overflow-hidden"> <div class="flex flex-1 overflow-hidden">
<Show when={showSidebar()}> <Show when={showSidebar() && sidebarOpen()}>
<aside <div class="w-72 h-full border-r-2 border-orange overflow-hidden flex-shrink-0">
class={`
flex-shrink-0 h-full
border-r-2 border-orange
transition-all duration-100
${getSidebarClasses()}
`}
>
<Sidebar /> <Sidebar />
</aside> </div>
<Show when={isMobile() && sidebarOpen()}>
<div
class="fixed inset-0 z-30 bg-black/80"
role="button"
tabIndex={0}
onClick={handleBackdropClick}
onKeyDown={handleBackdropKeyDown}
aria-label="Close sidebar"
/>
</Show>
</Show> </Show>
<main class="flex-1 overflow-hidden bg-black"> <main class="flex-1 overflow-hidden bg-black">
{props.children} {props.children}
</main> </main>
</div> </div>
<Show when={isMobile() && sidebarOpen()}>
<div
class="fixed inset-0 z-30 bg-black/80"
onClick={() => $sidebarOpen.set(false)}
/>
</Show>
</div> </div>
) )
} }

View File

@ -4,20 +4,24 @@
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { Show } from "solid-js" import { Show } from "solid-js"
import { A } from "@solidjs/router" import { A, useLocation } from "@solidjs/router"
import { useStore } from "@nanostores/solid" import { useStore } from "@nanostores/solid"
import { import {
$isAuthenticated, $isAuthenticated,
$sidebarOpen, $sidebarOpen,
$currentUser,
toggleSidebar, toggleSidebar,
openModal, openModal,
} from "../../stores" } from "../../stores"
import { IconButton } from "../UI/IconButton" import { IconButton } from "../UI/IconButton"
import { Badge } from "../UI/Badge" import { Badge } from "../UI/Badge"
import { Avatar } from "../UI/Avatar"
export function Header(): JSX.Element { export function Header(): JSX.Element {
const isAuthenticated = useStore($isAuthenticated) const isAuthenticated = useStore($isAuthenticated)
const sidebarOpen = useStore($sidebarOpen) const sidebarOpen = useStore($sidebarOpen)
const currentUser = useStore($currentUser)
const location = useLocation()
return ( return (
<header class="flex-shrink-0 bg-black border-b-4 border-orange"> <header class="flex-shrink-0 bg-black border-b-4 border-orange">
@ -73,6 +77,21 @@ export function Header(): JSX.Element {
size="sm" size="sm"
onClick={() => openModal("new-conversation")} onClick={() => openModal("new-conversation")}
/> />
<Show when={currentUser()} keyed>
{(user) => (
<A
href="/settings"
class={`flex items-center gap-2 px-2 py-1 border-2 ${
location.pathname === "/settings"
? "border-orange bg-orange text-black"
: "border-transparent text-white hover:text-orange"
}`}
>
<Avatar alt={user.display_name} size="xs" fallback={user.display_name.slice(0, 2)} />
<span class="font-pixel text-[10px] hidden sm:block">{user.display_name}</span>
</A>
)}
</Show>
</Show> </Show>
</nav> </nav>
</div> </div>

View File

@ -1,11 +1,10 @@
/** /**
* 8-bit styled sidebar component * Sidebar - Room list only
*/ */
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { Show, For } from "solid-js" import { Show, Index, createMemo } from "solid-js"
import type { Room, Participant } from "../../types" import type { Participant } from "../../types"
import { A, useLocation } from "@solidjs/router"
import { useStore } from "@nanostores/solid" import { useStore } from "@nanostores/solid"
import { import {
$currentUser, $currentUser,
@ -24,40 +23,30 @@ export function Sidebar(): JSX.Element {
const rooms = useStore($rooms) const rooms = useStore($rooms)
const activeRoomId = useStore($activeRoomId) const activeRoomId = useStore($activeRoomId)
const totalUnread = useStore($totalUnreadCount) const totalUnread = useStore($totalUnreadCount)
const location = useLocation()
const roomList = (): Room[] => { const sortedRooms = createMemo(() => {
const roomsObj = rooms() const arr = Object.values(rooms())
console.log("[Sidebar] rooms store:", roomsObj) return arr.sort((a, b) => {
const roomArray: Room[] = Object.values(roomsObj)
console.log("[Sidebar] roomArray length:", roomArray.length)
return roomArray.sort((a, b) => {
const aTime = a.last_message?.created_at ?? a.updated_at const aTime = a.last_message?.created_at ?? a.updated_at
const bTime = b.last_message?.created_at ?? b.updated_at const bTime = b.last_message?.created_at ?? b.updated_at
return new Date(bTime).getTime() - new Date(aTime).getTime() return new Date(bTime).getTime() - new Date(aTime).getTime()
}) })
} })
const isActive = (path: string): boolean => location.pathname === path
return ( return (
<div class="h-full flex flex-col bg-black"> <div class="flex flex-col h-full bg-black">
<div class="p-4 border-b-2 border-orange"> <div class="p-4 border-b-2 border-orange">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="font-pixel text-[10px] text-orange uppercase"> <h2 class="font-pixel text-[10px] text-orange uppercase">Messages</h2>
Messages
</h2>
<Show when={totalUnread() > 0}> <Show when={totalUnread() > 0}>
<Badge variant="primary" size="xs"> <Badge variant="primary" size="xs">{totalUnread()}</Badge>
{totalUnread()}
</Badge>
</Show> </Show>
</div> </div>
</div> </div>
<div class="p-2"> <div class="p-2">
<IconButton <IconButton
icon={<NewChatIcon />} icon={<PlusIcon />}
ariaLabel="New conversation" ariaLabel="New conversation"
variant="subtle" variant="subtle"
size="sm" size="sm"
@ -66,110 +55,66 @@ export function Sidebar(): JSX.Element {
/> />
</div> </div>
<nav class="flex-1 overflow-y-auto scrollbar-pixel"> <div class="flex-1 overflow-y-auto p-2">
<div class="p-2 space-y-1"> <Index each={sortedRooms()}>
<For each={roomList()}> {(room, idx) => {
{(room) => { const isActive = createMemo(() => activeRoomId() === room().id)
const isSelected = (): boolean => activeRoomId() === room.id const other = createMemo(() => room().participants?.find((p: Participant) => p.user_id !== currentUser()?.id))
const otherParticipant = (): Participant | undefined => const displayName = createMemo(() => room().name ?? other()?.display_name ?? "Chat")
room.participants?.find((p: Participant) => p.user_id !== currentUser()?.id)
return ( return (
<button <button
type="button" type="button"
onClick={() => setActiveRoom(room.id)} data-room-id={room().id}
class={` data-active={isActive()}
w-full flex items-center gap-3 p-3 onClick={() => setActiveRoom(room().id)}
border-2 transition-colors duration-100 style={{
${isSelected() width: "100%",
? "bg-orange text-black border-orange" display: "flex",
: "bg-black text-white border-transparent hover:border-orange" "align-items": "center",
} gap: "12px",
`} padding: "12px",
> "margin-bottom": "4px",
<Avatar border: isActive() ? "2px solid #FF5300" : "2px solid transparent",
alt={room.name ?? otherParticipant()?.display_name ?? "Chat"} background: isActive() ? "#FF5300" : "black",
size="sm" color: isActive() ? "black" : "white",
fallback={room.name?.slice(0, 2) ?? otherParticipant()?.display_name?.slice(0, 2)} cursor: "pointer",
/> "text-align": "left",
<div class="flex-1 min-w-0 text-left"> }}
<div class="flex items-center justify-between"> >
<span class="font-pixel text-[10px] truncate"> <Avatar
{room.name ?? otherParticipant()?.display_name ?? "Chat"} alt={displayName()}
</span> size="sm"
<Show when={room.unread_count > 0}> fallback={displayName().slice(0, 2)}
<Badge variant="primary" size="xs"> />
{room.unread_count} <div style={{ flex: 1, "min-width": 0 }}>
</Badge> <span class="font-pixel text-[10px] truncate block">
</Show> {displayName()}
</div> </span>
<Show when={room.last_message} keyed> <Show when={room().last_message}>
{(lastMsg) => ( <p
<p class={`font-pixel text-[8px] truncate mt-0.5 ${ class="font-pixel text-[8px] truncate mt-0.5"
isSelected() ? "text-black/70" : "text-gray" style={{ color: isActive() ? "rgba(0,0,0,0.7)" : "#888" }}
}`}> >
{lastMsg.content} {room().last_message?.content}
</p> </p>
)} </Show>
</Show> </div>
</div> </button>
</button> )
) }}
}} </Index>
</For> <Show when={sortedRooms().length === 0}>
</div>
<Show when={roomList().length === 0}>
<div class="p-4 text-center"> <div class="p-4 text-center">
<p class="font-pixel text-[8px] text-gray"> <p class="font-pixel text-[8px] text-gray">NO CONVERSATIONS YET</p>
NO CONVERSATIONS YET
</p>
<p class="font-pixel text-[8px] text-gray mt-2">
START A NEW CHAT TO BEGIN
</p>
</div> </div>
</Show> </Show>
</nav>
<div class="p-3 border-t-2 border-orange">
<Show when={currentUser()} keyed>
{(user) => (
<A
href="/settings"
class={`
flex items-center gap-3 p-2
border-2 transition-colors duration-100
${isActive("/settings")
? "bg-orange text-black border-orange"
: "border-transparent hover:border-orange"
}
`}
>
<Avatar
alt={user.display_name}
size="sm"
fallback={user.display_name.slice(0, 2)}
/>
<div class="flex-1 min-w-0">
<span class="font-pixel text-[10px] truncate block">
{user.display_name}
</span>
<span class={`font-pixel text-[8px] ${
isActive("/settings") ? "text-black/70" : "text-gray"
}`}>
@{user.username}
</span>
</div>
<SettingsIcon />
</A>
)}
</Show>
</div> </div>
</div> </div>
) )
} }
function NewChatIcon(): JSX.Element { function PlusIcon(): JSX.Element {
return ( return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="7" y="2" width="2" height="12" /> <rect x="7" y="2" width="2" height="12" />
@ -177,16 +122,3 @@ function NewChatIcon(): JSX.Element {
</svg> </svg>
) )
} }
function SettingsIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="text-gray">
<rect x="7" y="1" width="2" height="2" />
<rect x="5" y="3" width="6" height="2" />
<rect x="7" y="5" width="2" height="2" />
<rect x="7" y="9" width="2" height="2" />
<rect x="5" y="11" width="6" height="2" />
<rect x="7" y="13" width="2" height="2" />
</svg>
)
}

View File

@ -8,6 +8,9 @@ import type {
OneTimePreKey, OneTimePreKey,
DoubleRatchetState, DoubleRatchetState,
EncryptedMessage, EncryptedMessage,
X3DHHeader,
FullMessageHeader,
MessageHeader,
} from "../types" } from "../types"
import { DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types" import { DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types"
import { import {
@ -32,7 +35,7 @@ import {
getLatestSignedPreKey, getLatestSignedPreKey,
saveOneTimePreKeys, saveOneTimePreKeys,
getUnusedOneTimePreKeys, getUnusedOneTimePreKeys,
getOneTimePreKey, getOneTimePreKeyByPublicKey,
markOneTimePreKeyUsed, markOneTimePreKeyUsed,
saveRatchetState, saveRatchetState,
getRatchetState, getRatchetState,
@ -43,7 +46,8 @@ import { api } from "../lib/api-client"
import { import {
base64ToBytes, base64ToBytes,
bytesToBase64, bytesToBase64,
generateX25519KeyPair, importX25519PrivateKey,
importX25519PublicKey,
} from "./primitives" } from "./primitives"
class CryptoService { class CryptoService {
@ -51,6 +55,7 @@ class CryptoService {
private identityKeyPair: IdentityKeyPair | null = null private identityKeyPair: IdentityKeyPair | null = null
private signedPreKey: SignedPreKey | null = null private signedPreKey: SignedPreKey | null = null
private ratchetStates = new Map<string, DoubleRatchetState>() private ratchetStates = new Map<string, DoubleRatchetState>()
private pendingX3DHHeaders = new Map<string, X3DHHeader>()
private initialized = false private initialized = false
async initialize(userId: string): Promise<void> { async initialize(userId: string): Promise<void> {
@ -114,9 +119,19 @@ class CryptoService {
await saveOneTimePreKeys(this.userId, newPreKeys) await saveOneTimePreKeys(this.userId, newPreKeys)
} }
private async uploadPublicKeys(_oneTimePreKeys: OneTimePreKey[]): Promise<void> { private async uploadPublicKeys(oneTimePreKeys: OneTimePreKey[]): Promise<void> {
if (!this.userId) throw new Error("User ID not set") if (!this.userId) throw new Error("User ID not set")
await api.encryption.initializeKeys(this.userId) if (!this.identityKeyPair || !this.signedPreKey) {
throw new Error("Keys not generated")
}
await api.encryption.uploadKeys(this.userId, {
identity_key: this.identityKeyPair.x25519_public,
identity_key_ed25519: this.identityKeyPair.ed25519_public,
signed_prekey: this.signedPreKey.public_key,
signed_prekey_signature: this.signedPreKey.signature,
one_time_prekeys: oneTimePreKeys.map((k) => k.public_key),
})
} }
async establishSession(peerId: string): Promise<void> { async establishSession(peerId: string): Promise<void> {
@ -129,16 +144,23 @@ class CryptoService {
const x3dhResult = await initiateX3DH(this.identityKeyPair, peerBundle) const x3dhResult = await initiateX3DH(this.identityKeyPair, peerBundle)
const peerIdentityKey = base64ToBytes(peerBundle.identity_key) const peerSignedPrekey = base64ToBytes(peerBundle.signed_prekey)
const ratchetState = await initializeRatchetSender( const ratchetState = await initializeRatchetSender(
peerId, peerId,
x3dhResult.shared_key, x3dhResult.shared_key,
peerIdentityKey peerSignedPrekey
) )
this.ratchetStates.set(peerId, ratchetState) this.ratchetStates.set(peerId, ratchetState)
const x3dhHeader: X3DHHeader = {
identity_key: this.identityKeyPair.x25519_public,
ephemeral_key: x3dhResult.ephemeral_public_key,
one_time_prekey_id: x3dhResult.used_one_time_prekey ? peerBundle.one_time_prekey : null,
}
this.pendingX3DHHeaders.set(peerId, x3dhHeader)
const serialized = await serializeRatchetState(ratchetState) const serialized = await serializeRatchetState(ratchetState)
await saveRatchetState(serialized) await saveRatchetState(serialized)
} }
@ -147,17 +169,17 @@ class CryptoService {
peerId: string, peerId: string,
senderIdentityKey: string, senderIdentityKey: string,
ephemeralKey: string, ephemeralKey: string,
oneTimePreKeyId: string | null oneTimePreKeyPublic: string | null
): Promise<void> { ): Promise<void> {
if (this.identityKeyPair === null || this.signedPreKey === null) { if (this.identityKeyPair === null || this.signedPreKey === null) {
throw new Error("Keys not initialized") throw new Error("Keys not initialized")
} }
let oneTimePreKey: OneTimePreKey | null = null let oneTimePreKey: OneTimePreKey | null = null
if (oneTimePreKeyId !== null) { if (oneTimePreKeyPublic !== null) {
oneTimePreKey = await getOneTimePreKey(oneTimePreKeyId) oneTimePreKey = await getOneTimePreKeyByPublicKey(oneTimePreKeyPublic)
if (oneTimePreKey !== null) { if (oneTimePreKey !== null) {
await markOneTimePreKeyUsed(oneTimePreKeyId) await markOneTimePreKeyUsed(oneTimePreKey.id)
} }
} }
@ -169,12 +191,21 @@ class CryptoService {
ephemeralKey ephemeralKey
) )
const dhKeyPair = await generateX25519KeyPair() const signedPreKeyPrivate = await importX25519PrivateKey(
base64ToBytes(this.signedPreKey.private_key)
)
const signedPreKeyPublic = await importX25519PublicKey(
base64ToBytes(this.signedPreKey.public_key)
)
const signedPreKeyPair: CryptoKeyPair = {
privateKey: signedPreKeyPrivate,
publicKey: signedPreKeyPublic,
}
const ratchetState = await initializeRatchetReceiver( const ratchetState = await initializeRatchetReceiver(
peerId, peerId,
sharedKey, sharedKey,
dhKeyPair signedPreKeyPair
) )
this.ratchetStates.set(peerId, ratchetState) this.ratchetStates.set(peerId, ratchetState)
@ -200,10 +231,20 @@ class CryptoService {
const serialized = await serializeRatchetState(state) const serialized = await serializeRatchetState(state)
await saveRatchetState(serialized) await saveRatchetState(serialized)
const pendingX3DH = this.pendingX3DHHeaders.get(peerId)
const fullHeader: FullMessageHeader = {
ratchet: encrypted.header,
x3dh: pendingX3DH ?? undefined,
}
if (pendingX3DH) {
this.pendingX3DHHeaders.delete(peerId)
}
return { return {
ciphertext: bytesToBase64(encrypted.ciphertext), ciphertext: bytesToBase64(encrypted.ciphertext),
nonce: bytesToBase64(encrypted.nonce), nonce: bytesToBase64(encrypted.nonce),
header: JSON.stringify(encrypted.header), header: JSON.stringify(fullHeader),
} }
} }
@ -215,19 +256,34 @@ class CryptoService {
): Promise<string> { ): Promise<string> {
let state = await this.getRatchetState(peerId) let state = await this.getRatchetState(peerId)
const parsedHeader = JSON.parse(header) as EncryptedMessage["header"] const parsedHeader = JSON.parse(header) as FullMessageHeader | MessageHeader
let ratchetHeader: MessageHeader
let x3dhHeader: X3DHHeader | undefined
if ("ratchet" in parsedHeader) {
ratchetHeader = parsedHeader.ratchet
x3dhHeader = parsedHeader.x3dh
} else {
ratchetHeader = parsedHeader
}
const encryptedMessage: EncryptedMessage = { const encryptedMessage: EncryptedMessage = {
ciphertext: base64ToBytes(ciphertext), ciphertext: base64ToBytes(ciphertext),
nonce: base64ToBytes(nonce), nonce: base64ToBytes(nonce),
header: parsedHeader, header: ratchetHeader,
} }
if (state === null) { if (state === null) {
if (!x3dhHeader) {
throw new Error("Cannot establish session: missing X3DH header")
}
await this.handleIncomingSession( await this.handleIncomingSession(
peerId, peerId,
parsedHeader.dh_public_key, x3dhHeader.identity_key,
parsedHeader.dh_public_key, x3dhHeader.ephemeral_key,
null x3dhHeader.one_time_prekey_id
) )
state = await this.getRatchetState(peerId) state = await this.getRatchetState(peerId)
} }
@ -276,6 +332,23 @@ class CryptoService {
isInitialized(): boolean { isInitialized(): boolean {
return this.initialized return this.initialized
} }
async resetAllSessions(): Promise<void> {
this.ratchetStates.clear()
this.pendingX3DHHeaders.clear()
const database = indexedDB.open("encrypted-chat-keys", 1)
database.onsuccess = () => {
const db = database.result
const tx = db.transaction("ratchet_states", "readwrite")
tx.objectStore("ratchet_states").clear()
}
}
} }
export const cryptoService = new CryptoService() export const cryptoService = new CryptoService()
if (typeof window !== "undefined") {
(window as unknown as { resetCryptoSessions: () => Promise<void> }).resetCryptoSessions =
() => cryptoService.resetAllSessions()
}

View File

@ -4,6 +4,7 @@
// =================== // ===================
export * from "./primitives" export * from "./primitives"
export * from "./key-store" export * from "./key-store"
export * from "./message-store"
export * from "./x3dh" export * from "./x3dh"
export * from "./double-ratchet" export * from "./double-ratchet"
export * from "./crypto-service" export * from "./crypto-service"

View File

@ -266,6 +266,36 @@ export async function getOneTimePreKey(id: string): Promise<OneTimePreKey | null
} }
} }
export async function getOneTimePreKeyByPublicKey(publicKey: string): Promise<OneTimePreKey | null> {
const database = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, "readonly")
const store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
const request = store.getAll()
request.onsuccess = () => {
const results = request.result as StoredOneTimePreKey[]
const match = results.find((r) => r.public_key === publicKey)
if (!match) {
resolve(null)
return
}
resolve({
id: match.id,
private_key: match.private_key,
public_key: match.public_key,
is_used: match.is_used,
created_at: match.created_at,
})
}
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to find one-time prekey"))
})
}
export async function getUnusedOneTimePreKeys(userId: string): Promise<OneTimePreKey[]> { export async function getUnusedOneTimePreKeys(userId: string): Promise<OneTimePreKey[]> {
const database = await openDatabase() const database = await openDatabase()

View File

@ -0,0 +1,211 @@
// ===================
// © AngelaMos | 2025
// message-store.ts
// ===================
import type { Message } from "../types"
const DB_NAME = "encrypted-chat-messages"
const DB_VERSION = 1
const STORES = {
MESSAGES: "decrypted_messages",
} as const
let db: IDBDatabase | null = null
async function openDatabase(): Promise<IDBDatabase> {
if (db !== null) return db
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => {
reject(new Error("Failed to open message database"))
}
request.onsuccess = () => {
db = request.result
resolve(db)
}
request.onupgradeneeded = (event) => {
const database = (event.target as IDBOpenDBRequest).result
if (!database.objectStoreNames.contains(STORES.MESSAGES)) {
const messageStore = database.createObjectStore(STORES.MESSAGES, { keyPath: "id" })
messageStore.createIndex("room_id", "room_id", { unique: false })
messageStore.createIndex("created_at", "created_at", { unique: false })
messageStore.createIndex("room_created", ["room_id", "created_at"], { unique: false })
}
}
})
}
async function performTransaction<T>(
storeName: string,
mode: IDBTransactionMode,
operation: (store: IDBObjectStore) => IDBRequest<T>
): Promise<T> {
const database = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = database.transaction(storeName, mode)
const store = transaction.objectStore(storeName)
const request = operation(store)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(new Error(request.error?.message ?? "Database operation failed"))
})
}
export async function saveDecryptedMessage(message: Message): Promise<void> {
await performTransaction(
STORES.MESSAGES,
"readwrite",
(store) => store.put(message)
)
}
export async function saveDecryptedMessages(messages: Message[]): Promise<void> {
const database = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORES.MESSAGES, "readwrite")
const store = transaction.objectStore(STORES.MESSAGES)
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(new Error(transaction.error?.message ?? "Failed to save messages"))
for (const message of messages) {
store.put(message)
}
})
}
export async function getDecryptedMessage(messageId: string): Promise<Message | null> {
const result = await performTransaction<Message | undefined>(
STORES.MESSAGES,
"readonly",
(store) => store.get(messageId) as IDBRequest<Message | undefined>
)
return result ?? null
}
export async function getDecryptedMessages(roomId: string, limit?: number): Promise<Message[]> {
const database = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORES.MESSAGES, "readonly")
const store = transaction.objectStore(STORES.MESSAGES)
const index = store.index("room_id")
const request = index.getAll(roomId)
request.onsuccess = () => {
let results = request.result as Message[]
results = results.sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
)
if (limit !== undefined && limit > 0) {
results = results.slice(-limit)
}
resolve(results)
}
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get messages"))
})
}
export async function getLatestMessageTimestamp(roomId: string): Promise<string | null> {
const database = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORES.MESSAGES, "readonly")
const store = transaction.objectStore(STORES.MESSAGES)
const index = store.index("room_created")
const range = IDBKeyRange.bound([roomId, ""], [roomId, "\uffff"])
const request = index.openCursor(range, "prev")
request.onsuccess = () => {
const cursor = request.result
if (cursor !== null) {
const message = cursor.value as Message
resolve(message.created_at)
} else {
resolve(null)
}
}
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get latest timestamp"))
})
}
export async function deleteMessage(messageId: string): Promise<void> {
await performTransaction(
STORES.MESSAGES,
"readwrite",
(store) => store.delete(messageId)
)
}
export async function updateMessageId(oldId: string, newId: string): Promise<void> {
const message = await getDecryptedMessage(oldId)
if (message) {
await deleteMessage(oldId)
message.id = newId
await saveDecryptedMessage(message)
}
}
export async function clearRoomMessages(roomId: string): Promise<void> {
const database = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORES.MESSAGES, "readwrite")
const store = transaction.objectStore(STORES.MESSAGES)
const index = store.index("room_id")
const request = index.openCursor(IDBKeyRange.only(roomId))
request.onsuccess = () => {
const cursor = request.result
if (cursor !== null) {
cursor.delete()
cursor.continue()
}
}
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(new Error(transaction.error?.message ?? "Failed to clear room messages"))
})
}
export async function clearAllMessages(): Promise<void> {
await performTransaction(
STORES.MESSAGES,
"readwrite",
(store) => store.clear()
)
}
export async function getMessageCount(roomId: string): Promise<number> {
const database = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORES.MESSAGES, "readonly")
const store = transaction.objectStore(STORES.MESSAGES)
const index = store.index("room_id")
const request = index.count(IDBKeyRange.only(roomId))
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to count messages"))
})
}
export function closeDatabase(): void {
if (db !== null) {
db.close()
db = null
}
}

View File

@ -39,36 +39,50 @@ export async function x25519DeriveSharedSecret(
} }
export async function importX25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> { export async function importX25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> {
if (keyBytes.length === 32) {
return await subtle.importKey(
"raw",
keyBytes.buffer as ArrayBuffer,
{ name: "X25519" },
true,
[]
)
}
return await subtle.importKey( return await subtle.importKey(
"raw", "spki",
keyBytes.buffer as ArrayBuffer, keyBytes.buffer as ArrayBuffer,
{ { name: "X25519" },
name: "X25519",
},
true, true,
[] []
) )
} }
export async function importX25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> { export async function importX25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> {
if (keyBytes.length === 32) {
return await subtle.importKey(
"raw",
keyBytes.buffer as ArrayBuffer,
{ name: "X25519" },
true,
["deriveBits"]
)
}
return await subtle.importKey( return await subtle.importKey(
"raw", "pkcs8",
keyBytes.buffer as ArrayBuffer, keyBytes.buffer as ArrayBuffer,
{ { name: "X25519" },
name: "X25519",
},
true, true,
["deriveBits"] ["deriveBits"]
) )
} }
export async function exportPublicKey(key: CryptoKey): Promise<Uint8Array> { export async function exportPublicKey(key: CryptoKey): Promise<Uint8Array> {
const exported = await subtle.exportKey("raw", key) const exported = await subtle.exportKey("spki", key)
return new Uint8Array(exported) return new Uint8Array(exported)
} }
export async function exportPrivateKey(key: CryptoKey): Promise<Uint8Array> { export async function exportPrivateKey(key: CryptoKey): Promise<Uint8Array> {
const exported = await subtle.exportKey("raw", key) const exported = await subtle.exportKey("pkcs8", key)
return new Uint8Array(exported) return new Uint8Array(exported)
} }
@ -112,24 +126,38 @@ export async function ed25519Verify(
} }
export async function importEd25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> { export async function importEd25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> {
if (keyBytes.length === 32) {
return await subtle.importKey(
"raw",
keyBytes.buffer as ArrayBuffer,
{ name: "Ed25519" },
true,
["verify"]
)
}
return await subtle.importKey( return await subtle.importKey(
"raw", "spki",
keyBytes.buffer as ArrayBuffer, keyBytes.buffer as ArrayBuffer,
{ { name: "Ed25519" },
name: "Ed25519",
},
true, true,
["verify"] ["verify"]
) )
} }
export async function importEd25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> { export async function importEd25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> {
if (keyBytes.length === 32) {
return await subtle.importKey(
"raw",
keyBytes.buffer as ArrayBuffer,
{ name: "Ed25519" },
true,
["sign"]
)
}
return await subtle.importKey( return await subtle.importKey(
"raw", "pkcs8",
keyBytes.buffer as ArrayBuffer, keyBytes.buffer as ArrayBuffer,
{ { name: "Ed25519" },
name: "Ed25519",
},
true, true,
["sign"] ["sign"]
) )

View File

@ -117,7 +117,7 @@ export async function initiateX3DH(
recipientBundle: PreKeyBundle recipientBundle: PreKeyBundle
): Promise<X3DHResult> { ): Promise<X3DHResult> {
const signatureValid = await verifySignedPreKey( const signatureValid = await verifySignedPreKey(
recipientBundle.identity_key, recipientBundle.identity_key_ed25519,
recipientBundle.signed_prekey, recipientBundle.signed_prekey,
recipientBundle.signed_prekey_signature recipientBundle.signed_prekey_signature
) )

View File

@ -31,6 +31,7 @@ import {
wsManager, wsManager,
} from "../websocket" } from "../websocket"
import { roomService } from "../services" import { roomService } from "../services"
import { cryptoService, saveDecryptedMessage } from "../crypto"
export default function Chat(): JSX.Element { export default function Chat(): JSX.Element {
const activeRoom = useStore($activeRoom) const activeRoom = useStore($activeRoom)
@ -47,14 +48,13 @@ export default function Chat(): JSX.Element {
onMount(async () => { onMount(async () => {
const currentUserId = userId() const currentUserId = userId()
console.log("[Chat] onMount - userId:", currentUserId)
if (currentUserId) { if (currentUserId) {
console.log("[Chat] Connecting WebSocket and loading rooms...") try {
await cryptoService.initialize(currentUserId)
} catch {
}
connectWebSocket() connectWebSocket()
await roomService.loadRooms(currentUserId) await roomService.loadRooms(currentUserId)
console.log("[Chat] Rooms loaded")
} else {
console.log("[Chat] No userId, skipping room load")
} }
}) })
@ -62,7 +62,7 @@ export default function Chat(): JSX.Element {
disconnectWebSocket() disconnectWebSocket()
}) })
const handleSendMessage = (content: string): void => { const handleSendMessage = async (content: string): Promise<void> => {
const roomId = activeRoomId() const roomId = activeRoomId()
const room = activeRoom() const room = activeRoom()
const currentUserId = userId() const currentUserId = userId()
@ -88,27 +88,37 @@ export default function Chat(): JSX.Element {
const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2)}` const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2)}`
const now = new Date().toISOString() const now = new Date().toISOString()
addMessage(roomId, { const messageToSend = {
id: tempId, id: tempId,
room_id: roomId, room_id: roomId,
sender_id: currentUserId, sender_id: currentUserId,
sender_username: user?.username ?? "me", sender_username: user?.username ?? "me",
content, content,
status: "sending", status: "sending" as const,
is_encrypted: false, is_encrypted: true,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}) }
const sent = wsManager.sendEncryptedMessage( addMessage(roomId, messageToSend)
recipientId,
roomId,
content,
tempId
)
if (!sent) { try {
showToast("error", "SEND FAILED", "NOT CONNECTED") const encrypted = await cryptoService.encrypt(recipientId, content)
const sent = wsManager.sendEncryptedMessage(
recipientId,
roomId,
encrypted,
tempId
)
if (sent) {
void saveDecryptedMessage(messageToSend)
} else {
showToast("error", "SEND FAILED", "NOT CONNECTED")
}
} catch (error) {
console.error("[Chat] Encryption failed:", error)
showToast("error", "SEND FAILED", "ENCRYPTION ERROR")
} }
} }

View File

@ -16,6 +16,7 @@ import type {
} from "../types" } from "../types"
import { isPublicKeyCredential } from "../types/guards" import { isPublicKeyCredential } from "../types/guards"
import { setCurrentUser, logout as storeLogout } from "../stores" import { setCurrentUser, logout as storeLogout } from "../stores"
import { cryptoService } from "../crypto"
interface PublicKeyCredentialStatic { interface PublicKeyCredentialStatic {
isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean> isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean>
@ -131,6 +132,7 @@ export async function register(
}) })
setCurrentUser(user) setCurrentUser(user)
await cryptoService.initialize(user.id)
return user return user
} }
@ -161,6 +163,7 @@ export async function login(username?: string): Promise<User> {
}) })
setCurrentUser(user) setCurrentUser(user)
await cryptoService.initialize(user.id)
return user return user
} }

View File

@ -10,18 +10,21 @@ import {
addRoom, addRoom,
setRoomMessages, setRoomMessages,
setHasMore, setHasMore,
$userId,
} from "../stores" } from "../stores"
import {
getDecryptedMessages,
getDecryptedMessage,
saveDecryptedMessage,
cryptoService,
} from "../crypto"
export async function loadRooms(userId: string): Promise<Room[]> { export async function loadRooms(userId: string): Promise<Room[]> {
console.log("[RoomService] loadRooms called with userId:", userId)
try { try {
const response = await api.rooms.list(userId) const response = await api.rooms.list(userId)
console.log("[RoomService] API response:", response)
console.log("[RoomService] rooms count:", response.rooms.length)
setRooms(response.rooms) setRooms(response.rooms)
return response.rooms return response.rooms
} catch (err) { } catch {
console.error("[RoomService] Failed to load rooms:", err)
return [] return []
} }
} }
@ -51,28 +54,85 @@ export async function loadMessages(
offset: number = 0 offset: number = 0
): Promise<Message[]> { ): Promise<Message[]> {
try { try {
const localMessages = await getDecryptedMessages(roomId, limit)
const localMessageIds = new Set(localMessages.map((m) => m.id))
if (localMessages.length > 0) {
setRoomMessages(roomId, localMessages)
}
const response = await api.rooms.getMessages(roomId, limit, offset) const response = await api.rooms.getMessages(roomId, limit, offset)
const messages: Message[] = response.messages.map((msg) => ({ const serverMessages = response.messages.reverse()
id: msg.id,
room_id: msg.room_id, const newMessages: Message[] = []
sender_id: msg.sender_id,
sender_username: msg.sender_username, const currentUserId = $userId.get()
content: "[Encrypted message]",
status: "delivered" as const, for (const msg of serverMessages) {
is_encrypted: true, if (localMessageIds.has(msg.id)) {
encrypted_content: msg.ciphertext, continue
nonce: msg.nonce, }
header: msg.header,
created_at: msg.created_at, let content = "[Encrypted - from another session]"
updated_at: msg.created_at, const isOwnMessage = msg.sender_id === currentUserId
}))
const reversedMessages = messages.reverse() if (isOwnMessage) {
setRoomMessages(roomId, reversedMessages) const localCopy = await getDecryptedMessage(msg.id)
if (localCopy) {
content = localCopy.content
} else {
content = "[Your message - not stored locally]"
}
} else {
try {
content = await cryptoService.decrypt(
msg.sender_id,
msg.ciphertext,
msg.nonce,
msg.header
)
} catch {
content = "[Encrypted - from another session]"
}
}
const decryptedMessage: Message = {
id: msg.id,
room_id: msg.room_id,
sender_id: msg.sender_id,
sender_username: msg.sender_username,
content,
status: "delivered" as const,
is_encrypted: true,
encrypted_content: msg.ciphertext,
nonce: msg.nonce,
header: msg.header,
created_at: msg.created_at,
updated_at: msg.created_at,
}
if (!content.startsWith("[Encrypted") && !content.startsWith("[Your message")) {
void saveDecryptedMessage(decryptedMessage)
}
newMessages.push(decryptedMessage)
}
const allMessages = [...localMessages, ...newMessages]
const uniqueMessages = Array.from(
new Map(allMessages.map((m) => [m.id, m])).values()
).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
setRoomMessages(roomId, uniqueMessages)
setHasMore(roomId, response.has_more) setHasMore(roomId, response.has_more)
return reversedMessages return uniqueMessages
} catch (err) { } catch (err) {
console.error("[RoomService] Failed to load messages:", err) console.error("[RoomService] Failed to load messages:", err)
return [] const localMessages = await getDecryptedMessages(roomId, limit)
if (localMessages.length > 0) {
setRoomMessages(roomId, localMessages)
}
return localMessages
} }
} }

View File

@ -4,6 +4,7 @@
// =================== // ===================
export interface PreKeyBundle { export interface PreKeyBundle {
identity_key: string identity_key: string
identity_key_ed25519: string
signed_prekey: string signed_prekey: string
signed_prekey_signature: string signed_prekey_signature: string
one_time_prekey: string | null one_time_prekey: string | null
@ -62,6 +63,11 @@ export interface MessageHeader {
previous_chain_length: number previous_chain_length: number
} }
export interface FullMessageHeader {
ratchet: MessageHeader
x3dh?: X3DHHeader
}
export interface IdentityKeyPair { export interface IdentityKeyPair {
x25519_private: string x25519_private: string
x25519_public: string x25519_public: string

View File

@ -106,7 +106,9 @@ export interface WSOutgoingEncryptedMessage {
type: "encrypted_message" type: "encrypted_message"
recipient_id: string recipient_id: string
room_id: string room_id: string
plaintext: string ciphertext: string
nonce: string
header: string
temp_id: string temp_id: string
} }

View File

@ -38,16 +38,30 @@ import {
setUserTyping, setUserTyping,
} from "../stores/typing.store" } from "../stores/typing.store"
import { showToast } from "../stores/ui.store" import { showToast } from "../stores/ui.store"
import { cryptoService, saveDecryptedMessage, updateMessageId } from "../crypto"
type MessageHandler<T extends WSMessage> = (message: T) => void type MessageHandler<T extends WSMessage> = (message: T) => void
const encryptedMessageHandler: MessageHandler<EncryptedMessageWS> = (message) => { async function encryptedMessageHandler(message: EncryptedMessageWS): Promise<void> {
let decryptedContent: string
try {
decryptedContent = await cryptoService.decrypt(
message.sender_id,
message.ciphertext,
message.nonce,
message.header
)
} catch {
decryptedContent = "[Encrypted message - decryption failed]"
}
const chatMessage: Message = { const chatMessage: Message = {
id: message.message_id, id: message.message_id,
room_id: message.room_id, room_id: message.room_id,
sender_id: message.sender_id, sender_id: message.sender_id,
sender_username: message.sender_username, sender_username: message.sender_username,
content: message.content, content: decryptedContent,
status: "delivered", status: "delivered",
is_encrypted: true, is_encrypted: true,
encrypted_content: message.ciphertext, encrypted_content: message.ciphertext,
@ -58,6 +72,7 @@ const encryptedMessageHandler: MessageHandler<EncryptedMessageWS> = (message) =>
} }
addMessage(message.room_id, chatMessage) addMessage(message.room_id, chatMessage)
void saveDecryptedMessage(chatMessage)
} }
const typingIndicatorHandler: MessageHandler<TypingIndicatorWS> = (message) => { const typingIndicatorHandler: MessageHandler<TypingIndicatorWS> = (message) => {
@ -115,11 +130,12 @@ const messageSentHandler: MessageHandler<MessageSentWS> = (message) => {
} }
confirmPendingMessage(message.room_id, message.temp_id, confirmedMessage) confirmPendingMessage(message.room_id, message.temp_id, confirmedMessage)
void updateMessageId(message.temp_id, message.message_id)
} }
export function handleWSMessage(message: WSMessage): void { export function handleWSMessage(message: WSMessage): void {
if (isEncryptedMessageWS(message)) { if (isEncryptedMessageWS(message)) {
encryptedMessageHandler(message) void encryptedMessageHandler(message)
} else if (isTypingIndicatorWS(message)) { } else if (isTypingIndicatorWS(message)) {
typingIndicatorHandler(message) typingIndicatorHandler(message)
} else if (isPresenceUpdateWS(message)) { } else if (isPresenceUpdateWS(message)) {
@ -135,8 +151,8 @@ export function handleWSMessage(message: WSMessage): void {
} }
} }
export function handleEncryptedMessage(message: EncryptedMessageWS): void { export async function handleEncryptedMessage(message: EncryptedMessageWS): Promise<void> {
encryptedMessageHandler(message) await encryptedMessageHandler(message)
} }
export function handleTypingIndicator(message: TypingIndicatorWS): void { export function handleTypingIndicator(message: TypingIndicatorWS): void {

View File

@ -108,14 +108,16 @@ class WebSocketManager {
sendEncryptedMessage( sendEncryptedMessage(
recipientId: string, recipientId: string,
roomId: string, roomId: string,
plaintext: string, encrypted: { ciphertext: string; nonce: string; header: string },
tempId: string tempId: string
): boolean { ): boolean {
const message: WSOutgoingEncryptedMessage = { const message: WSOutgoingEncryptedMessage = {
type: "encrypted_message", type: "encrypted_message",
recipient_id: recipientId, recipient_id: recipientId,
room_id: roomId, room_id: roomId,
plaintext, ciphertext: encrypted.ciphertext,
nonce: encrypted.nonce,
header: encrypted.header,
temp_id: tempId, temp_id: tempId,
} }
return this.send(message) return this.send(message)

View File

@ -0,0 +1,278 @@
# =============================================================================
# AngelaMos | 2025
# Justfile - Encrypted P2P Chat
# =============================================================================
set dotenv-load
set export
set shell := ["bash", "-uc"]
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
project := file_name(justfile_directory())
version := `git describe --tags --always 2>/dev/null || echo "dev"`
# =============================================================================
# Default
# =============================================================================
default:
@just --list --unsorted
# =============================================================================
# Setup
# =============================================================================
[group('setup')]
setup: setup-backend setup-frontend env
@echo "Setup complete!"
[group('setup')]
setup-backend:
@echo "Setting up backend..."
cd backend && uv venv ../.venv
cd backend && uv pip install -e .[dev]
@echo "Backend setup complete!"
[group('setup')]
setup-frontend:
@echo "Setting up frontend..."
cd frontend && npm install
@echo "Frontend setup complete!"
[group('setup')]
env:
@echo "Creating .env files..."
@if [ ! -f .env ]; then cp .env.example .env; echo "Created root .env"; fi
@if [ ! -f frontend/.env ]; then cp frontend/.env.example frontend/.env; echo "Created frontend/.env"; fi
@echo ".env files created! Please update with your values."
# =============================================================================
# Docker Compose (Dev)
# =============================================================================
[group('dev')]
dev: build-dev up-dev
[group('dev')]
build-dev *ARGS:
@echo "Building development Docker images..."
docker compose -f dev.compose.yml build {{ARGS}}
[group('dev')]
up-dev *ARGS:
@echo "Starting development environment..."
docker compose -f dev.compose.yml up {{ARGS}}
[group('dev')]
start-dev *ARGS:
@echo "Starting development environment..."
docker compose -f dev.compose.yml up -d {{ARGS}}
@echo "Development environment started!"
@echo "Frontend: http://localhost:5173"
@echo "Backend: http://localhost:8000"
@echo "Nginx: http://localhost"
[group('dev')]
down-dev *ARGS:
@echo "Stopping development environment..."
docker compose -f dev.compose.yml down {{ARGS}}
[group('dev')]
stop-dev:
docker compose -f dev.compose.yml stop
[group('dev')]
rebuild-dev:
docker compose -f dev.compose.yml build --no-cache
[group('dev')]
logs-dev *SERVICE:
docker compose -f dev.compose.yml logs -f {{SERVICE}}
[group('dev')]
ps-dev:
docker compose -f dev.compose.yml ps
[group('dev')]
shell-dev service='backend':
docker compose -f dev.compose.yml exec -it {{service}} /bin/bash
# =============================================================================
# Docker Compose (Production)
# =============================================================================
[group('prod')]
prod: build-prod up-prod
[group('prod')]
build-prod *ARGS:
@echo "Building production Docker images..."
docker compose build {{ARGS}}
[group('prod')]
up-prod *ARGS:
@echo "Starting production environment..."
docker compose up {{ARGS}}
[group('prod')]
start-prod *ARGS:
@echo "Starting production environment..."
docker compose up -d {{ARGS}}
@echo "Production environment started!"
@echo "Application: http://localhost"
[group('prod')]
down-prod *ARGS:
@echo "Stopping production environment..."
docker compose down {{ARGS}}
[group('prod')]
stop-prod:
docker compose stop
[group('prod')]
rebuild-prod:
docker compose -f build --no-cache
[group('prod')]
logs-prod *SERVICE:
docker compose logs -f {{SERVICE}}
[group('prod')]
ps-prod:
docker compose ps
[group('prod')]
shell-prod service='backend':
docker compose exec -it {{service}} /bin/bash
# =============================================================================
# Testing
# =============================================================================
[group('test')]
test-backend *ARGS:
@echo "Running backend tests..."
cd backend && uv run pytest tests/ -v {{ARGS}}
[group('test')]
test: test-backend
# =============================================================================
# Linting and Formatting
# =============================================================================
[group('lint')]
ruff *ARGS:
cd backend && uv run ruff check . {{ARGS}}
[group('lint')]
ruff-fix:
cd backend && uv run ruff check . --fix
cd backend && uv run ruff format .
[group('lint')]
ruff-format:
cd backend && uv run ruff format .
[group('lint')]
lint: ruff
# =============================================================================
# Type Checking
# =============================================================================
[group('types')]
mypy *ARGS:
cd backend && uv run mypy app {{ARGS}}
[group('types')]
typecheck: mypy
# =============================================================================
# Database (Docker)
# =============================================================================
[group('db')]
migrate *ARGS:
docker compose -f dev.compose.yml exec backend alembic upgrade {{ARGS}}
[group('db')]
migration message:
docker compose -f dev.compose.yml exec backend alembic revision --autogenerate -m "{{message}}"
[group('db')]
rollback:
docker compose -f dev.compose.yml exec backend alembic downgrade -1
[group('db')]
db-history:
docker compose -f dev.compose.yml exec backend alembic history --verbose
[group('db')]
db-current:
docker compose -f dev.compose.yml exec backend alembic current
# =============================================================================
# Database (Local - no Docker)
# =============================================================================
[group('db-local')]
migrate-local *ARGS:
cd backend && uv run alembic upgrade {{ARGS}}
[group('db-local')]
migration-local message:
cd backend && uv run alembic revision --autogenerate -m "{{message}}"
[group('db-local')]
rollback-local:
cd backend && uv run alembic downgrade -1
[group('db-local')]
db-history-local:
cd backend && uv run alembic history --verbose
[group('db-local')]
db-current-local:
cd backend && uv run alembic current
# =============================================================================
# CI / Quality
# =============================================================================
[group('ci')]
ci: lint typecheck test
[group('ci')]
check: ruff mypy
# =============================================================================
# Cleanup
# =============================================================================
[group('util')]
[confirm("Remove all containers, volumes, and build artifacts?")]
clean:
@echo "Cleaning up..."
-docker compose -f dev.compose.yml down -v
-docker compose down -v
-rm -rf frontend/node_modules
-rm -rf frontend/dist
-rm -rf .venv
-rm -rf backend/.venv
-rm -rf backend/__pycache__
-rm -rf backend/.pytest_cache
-rm -rf backend/.mypy_cache
-rm -rf backend/.ruff_cache
@echo "Cleanup complete!"
# =============================================================================
# Utilities
# =============================================================================
[group('util')]
info:
@echo "Project: {{project}}"
@echo "Version: {{version}}"
@echo "OS: {{os()}} ({{arch()}})"