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
RP_ID=localhost
RP_NAME=Encrypted P2P Chat
RP_NAME="Encrypted P2P Chat"
RP_ORIGIN=http://localhost
# Frontend (Vite requires VITE_ prefix)
@ -48,7 +48,7 @@ VITE_WS_URL=ws://localhost:8000
VITE_RP_ID=localhost
# CORS
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
CORS_ORIGINS='["http://localhost:3000","http://localhost:5173"]'
# WebSocket
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
- **Node.js 20.19+ or 22.12+** (required for Vite 7)
- **Python 3.13+** (latest stable)
- **uv** (Python package manager) - `curl -LsSf https://astral.sh/uv/install.sh | sh`
- Make
### Setup
@ -161,10 +162,9 @@ encrypted-p2p-chat/
```bash
cd backend
python -m venv ../.venv
source ../.venv/bin/activate
pip install -e .[dev]
python -m pytest tests/ -v
uv venv ../.venv
uv pip install -e .[dev]
uv run pytest tests/ -v
```
### Frontend Development
@ -189,7 +189,7 @@ Or manually:
```bash
cd backend
python -m pytest tests/ -v
uv run pytest tests/ -v
```
## Environment Variables

View File

@ -4,9 +4,11 @@ Encryption endpoints for X3DH prekey bundles
"""
import logging
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.Base import get_session
@ -19,6 +21,14 @@ logger = logging.getLogger(__name__)
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(
"/prekey-bundle/{user_id}",
status_code = status.HTTP_200_OK,
@ -48,6 +58,7 @@ async def initialize_keys(
) -> dict[str,
str]:
"""
[DEPRECATED] Server-side key generation - kept for backwards compatibility
Initializes encryption keys for a user
"""
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)
async def rotate_signed_prekey(
user_id: UUID,

View File

@ -140,8 +140,6 @@ async def list_rooms(
"""
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))
if not user:
@ -151,8 +149,6 @@ async def list_rooms(
)
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] = []

View File

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

View File

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

View File

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

View File

@ -266,6 +266,53 @@ class MessageService:
logger.error("Database error saving ratchet state: %s", 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(
self,
session: AsyncSession,
@ -275,6 +322,7 @@ class MessageService:
room_id: str | None = None,
) -> Any:
"""
[DEPRECATED] Server-side encryption - kept for backwards compatibility
Encrypts message with Double Ratchet and stores in SurrealDB
"""
ratchet_state_statement = select(RatchetState).where(

View File

@ -42,6 +42,113 @@ class PrekeyService:
"""
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(
self,
session: AsyncSession,
@ -239,6 +346,7 @@ class PrekeyService:
bundle = PreKeyBundle(
identity_key = identity_key.public_key,
identity_key_ed25519 = identity_key.public_key_ed25519,
signed_prekey = signed_prekey.public_key,
signed_prekey_signature = signed_prekey.signature,
one_time_prekey = one_time_prekey_public

View File

@ -91,16 +91,18 @@ class WebSocketService:
Any]
) -> None:
"""
Process encrypted message from client and forward to recipient
Process client-encrypted message and forward to recipient (pass-through)
"""
try:
recipient_id = UUID(message.get("recipient_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", "")
if not plaintext:
logger.error("Missing plaintext in message from %s", user_id)
if not ciphertext or not nonce or not header:
logger.error("Missing encryption fields in message from %s", user_id)
return
if not room_id:
@ -108,11 +110,13 @@ class WebSocketService:
return
async with async_session_maker() as session:
result = await message_service.send_encrypted_message(
result = await message_service.store_encrypted_message(
session,
user_id,
recipient_id,
plaintext,
ciphertext,
nonce,
header,
room_id,
)
@ -121,15 +125,15 @@ class WebSocketService:
sender_id = str(user_id),
recipient_id = str(recipient_id),
room_id = room_id,
content = plaintext,
ciphertext = result.ciphertext if hasattr(result, 'ciphertext') else "",
nonce = result.nonce if hasattr(result, 'nonce') else "",
header = result.header if hasattr(result, 'header') else "",
content = "",
ciphertext = ciphertext,
nonce = nonce,
header = header,
sender_username = result.sender_username if hasattr(result, 'sender_username') else ""
)
is_recipient_connected = connection_manager.is_user_connected(recipient_id)
logger.warning(
logger.debug(
"Sending to recipient %s - connected: %s",
recipient_id,
is_recipient_connected
@ -139,7 +143,7 @@ class WebSocketService:
recipient_id,
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(
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
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
@ -19,7 +19,7 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/*
COPY backend/pyproject.toml ./
RUN pip install -e .[dev]
RUN uv pip install --system -e .[dev]
COPY backend/ .

View File

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

View File

@ -17,23 +17,23 @@ interface MessageBubbleProps {
export function MessageBubble(props: MessageBubbleProps): JSX.Element {
const bubbleClasses = (): string => {
const base = "max-w-[80%] p-3"
const base = "max-w-[70%] p-4"
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 (
<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()}>
<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}
</div>
</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}
</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"
@ -21,59 +21,30 @@ export function AppShell(props: AppShellProps): JSX.Element {
const showSidebar = (): boolean => props.showSidebar ?? 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 (
<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()}>
<Header />
</Show>
<div class="flex-1 flex overflow-hidden">
<Show when={showSidebar()}>
<aside
class={`
flex-shrink-0 h-full
border-r-2 border-orange
transition-all duration-100
${getSidebarClasses()}
`}
>
<div class="flex flex-1 overflow-hidden">
<Show when={showSidebar() && sidebarOpen()}>
<div class="w-72 h-full border-r-2 border-orange overflow-hidden flex-shrink-0">
<Sidebar />
</aside>
<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>
</div>
</Show>
<main class="flex-1 overflow-hidden bg-black">
{props.children}
</main>
</div>
<Show when={isMobile() && sidebarOpen()}>
<div
class="fixed inset-0 z-30 bg-black/80"
onClick={() => $sidebarOpen.set(false)}
/>
</Show>
</div>
)
}

View File

@ -4,20 +4,24 @@
import type { JSX } 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 {
$isAuthenticated,
$sidebarOpen,
$currentUser,
toggleSidebar,
openModal,
} from "../../stores"
import { IconButton } from "../UI/IconButton"
import { Badge } from "../UI/Badge"
import { Avatar } from "../UI/Avatar"
export function Header(): JSX.Element {
const isAuthenticated = useStore($isAuthenticated)
const sidebarOpen = useStore($sidebarOpen)
const currentUser = useStore($currentUser)
const location = useLocation()
return (
<header class="flex-shrink-0 bg-black border-b-4 border-orange">
@ -73,6 +77,21 @@ export function Header(): JSX.Element {
size="sm"
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>
</nav>
</div>

View File

@ -1,11 +1,10 @@
/**
* 8-bit styled sidebar component
* Sidebar - Room list only
*/
import type { JSX } from "solid-js"
import { Show, For } from "solid-js"
import type { Room, Participant } from "../../types"
import { A, useLocation } from "@solidjs/router"
import { Show, Index, createMemo } from "solid-js"
import type { Participant } from "../../types"
import { useStore } from "@nanostores/solid"
import {
$currentUser,
@ -24,40 +23,30 @@ export function Sidebar(): JSX.Element {
const rooms = useStore($rooms)
const activeRoomId = useStore($activeRoomId)
const totalUnread = useStore($totalUnreadCount)
const location = useLocation()
const roomList = (): Room[] => {
const roomsObj = rooms()
console.log("[Sidebar] rooms store:", roomsObj)
const roomArray: Room[] = Object.values(roomsObj)
console.log("[Sidebar] roomArray length:", roomArray.length)
return roomArray.sort((a, b) => {
const sortedRooms = createMemo(() => {
const arr = Object.values(rooms())
return arr.sort((a, b) => {
const aTime = a.last_message?.created_at ?? a.updated_at
const bTime = b.last_message?.created_at ?? b.updated_at
return new Date(bTime).getTime() - new Date(aTime).getTime()
})
}
const isActive = (path: string): boolean => location.pathname === path
})
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="flex items-center justify-between">
<h2 class="font-pixel text-[10px] text-orange uppercase">
Messages
</h2>
<h2 class="font-pixel text-[10px] text-orange uppercase">Messages</h2>
<Show when={totalUnread() > 0}>
<Badge variant="primary" size="xs">
{totalUnread()}
</Badge>
<Badge variant="primary" size="xs">{totalUnread()}</Badge>
</Show>
</div>
</div>
<div class="p-2">
<IconButton
icon={<NewChatIcon />}
icon={<PlusIcon />}
ariaLabel="New conversation"
variant="subtle"
size="sm"
@ -66,110 +55,66 @@ export function Sidebar(): JSX.Element {
/>
</div>
<nav class="flex-1 overflow-y-auto scrollbar-pixel">
<div class="p-2 space-y-1">
<For each={roomList()}>
{(room) => {
const isSelected = (): boolean => activeRoomId() === room.id
const otherParticipant = (): Participant | undefined =>
room.participants?.find((p: Participant) => p.user_id !== currentUser()?.id)
<div class="flex-1 overflow-y-auto p-2">
<Index each={sortedRooms()}>
{(room, idx) => {
const isActive = createMemo(() => activeRoomId() === room().id)
const other = createMemo(() => room().participants?.find((p: Participant) => p.user_id !== currentUser()?.id))
const displayName = createMemo(() => room().name ?? other()?.display_name ?? "Chat")
return (
<button
type="button"
onClick={() => setActiveRoom(room.id)}
class={`
w-full flex items-center gap-3 p-3
border-2 transition-colors duration-100
${isSelected()
? "bg-orange text-black border-orange"
: "bg-black text-white border-transparent hover:border-orange"
}
`}
>
<Avatar
alt={room.name ?? otherParticipant()?.display_name ?? "Chat"}
size="sm"
fallback={room.name?.slice(0, 2) ?? otherParticipant()?.display_name?.slice(0, 2)}
/>
<div class="flex-1 min-w-0 text-left">
<div class="flex items-center justify-between">
<span class="font-pixel text-[10px] truncate">
{room.name ?? otherParticipant()?.display_name ?? "Chat"}
</span>
<Show when={room.unread_count > 0}>
<Badge variant="primary" size="xs">
{room.unread_count}
</Badge>
</Show>
</div>
<Show when={room.last_message} keyed>
{(lastMsg) => (
<p class={`font-pixel text-[8px] truncate mt-0.5 ${
isSelected() ? "text-black/70" : "text-gray"
}`}>
{lastMsg.content}
</p>
)}
</Show>
</div>
</button>
)
}}
</For>
</div>
<Show when={roomList().length === 0}>
return (
<button
type="button"
data-room-id={room().id}
data-active={isActive()}
onClick={() => setActiveRoom(room().id)}
style={{
width: "100%",
display: "flex",
"align-items": "center",
gap: "12px",
padding: "12px",
"margin-bottom": "4px",
border: isActive() ? "2px solid #FF5300" : "2px solid transparent",
background: isActive() ? "#FF5300" : "black",
color: isActive() ? "black" : "white",
cursor: "pointer",
"text-align": "left",
}}
>
<Avatar
alt={displayName()}
size="sm"
fallback={displayName().slice(0, 2)}
/>
<div style={{ flex: 1, "min-width": 0 }}>
<span class="font-pixel text-[10px] truncate block">
{displayName()}
</span>
<Show when={room().last_message}>
<p
class="font-pixel text-[8px] truncate mt-0.5"
style={{ color: isActive() ? "rgba(0,0,0,0.7)" : "#888" }}
>
{room().last_message?.content}
</p>
</Show>
</div>
</button>
)
}}
</Index>
<Show when={sortedRooms().length === 0}>
<div class="p-4 text-center">
<p class="font-pixel text-[8px] text-gray">
NO CONVERSATIONS YET
</p>
<p class="font-pixel text-[8px] text-gray mt-2">
START A NEW CHAT TO BEGIN
</p>
<p class="font-pixel text-[8px] text-gray">NO CONVERSATIONS YET</p>
</div>
</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>
)
}
function NewChatIcon(): JSX.Element {
function PlusIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="7" y="2" width="2" height="12" />
@ -177,16 +122,3 @@ function NewChatIcon(): JSX.Element {
</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,
DoubleRatchetState,
EncryptedMessage,
X3DHHeader,
FullMessageHeader,
MessageHeader,
} from "../types"
import { DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types"
import {
@ -32,7 +35,7 @@ import {
getLatestSignedPreKey,
saveOneTimePreKeys,
getUnusedOneTimePreKeys,
getOneTimePreKey,
getOneTimePreKeyByPublicKey,
markOneTimePreKeyUsed,
saveRatchetState,
getRatchetState,
@ -43,7 +46,8 @@ import { api } from "../lib/api-client"
import {
base64ToBytes,
bytesToBase64,
generateX25519KeyPair,
importX25519PrivateKey,
importX25519PublicKey,
} from "./primitives"
class CryptoService {
@ -51,6 +55,7 @@ class CryptoService {
private identityKeyPair: IdentityKeyPair | null = null
private signedPreKey: SignedPreKey | null = null
private ratchetStates = new Map<string, DoubleRatchetState>()
private pendingX3DHHeaders = new Map<string, X3DHHeader>()
private initialized = false
async initialize(userId: string): Promise<void> {
@ -114,9 +119,19 @@ class CryptoService {
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")
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> {
@ -129,16 +144,23 @@ class CryptoService {
const x3dhResult = await initiateX3DH(this.identityKeyPair, peerBundle)
const peerIdentityKey = base64ToBytes(peerBundle.identity_key)
const peerSignedPrekey = base64ToBytes(peerBundle.signed_prekey)
const ratchetState = await initializeRatchetSender(
peerId,
x3dhResult.shared_key,
peerIdentityKey
peerSignedPrekey
)
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)
await saveRatchetState(serialized)
}
@ -147,17 +169,17 @@ class CryptoService {
peerId: string,
senderIdentityKey: string,
ephemeralKey: string,
oneTimePreKeyId: string | null
oneTimePreKeyPublic: string | null
): Promise<void> {
if (this.identityKeyPair === null || this.signedPreKey === null) {
throw new Error("Keys not initialized")
}
let oneTimePreKey: OneTimePreKey | null = null
if (oneTimePreKeyId !== null) {
oneTimePreKey = await getOneTimePreKey(oneTimePreKeyId)
if (oneTimePreKeyPublic !== null) {
oneTimePreKey = await getOneTimePreKeyByPublicKey(oneTimePreKeyPublic)
if (oneTimePreKey !== null) {
await markOneTimePreKeyUsed(oneTimePreKeyId)
await markOneTimePreKeyUsed(oneTimePreKey.id)
}
}
@ -169,12 +191,21 @@ class CryptoService {
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(
peerId,
sharedKey,
dhKeyPair
signedPreKeyPair
)
this.ratchetStates.set(peerId, ratchetState)
@ -200,10 +231,20 @@ class CryptoService {
const serialized = await serializeRatchetState(state)
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 {
ciphertext: bytesToBase64(encrypted.ciphertext),
nonce: bytesToBase64(encrypted.nonce),
header: JSON.stringify(encrypted.header),
header: JSON.stringify(fullHeader),
}
}
@ -215,19 +256,34 @@ class CryptoService {
): Promise<string> {
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 = {
ciphertext: base64ToBytes(ciphertext),
nonce: base64ToBytes(nonce),
header: parsedHeader,
header: ratchetHeader,
}
if (state === null) {
if (!x3dhHeader) {
throw new Error("Cannot establish session: missing X3DH header")
}
await this.handleIncomingSession(
peerId,
parsedHeader.dh_public_key,
parsedHeader.dh_public_key,
null
x3dhHeader.identity_key,
x3dhHeader.ephemeral_key,
x3dhHeader.one_time_prekey_id
)
state = await this.getRatchetState(peerId)
}
@ -276,6 +332,23 @@ class CryptoService {
isInitialized(): boolean {
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()
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 "./key-store"
export * from "./message-store"
export * from "./x3dh"
export * from "./double-ratchet"
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[]> {
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> {
if (keyBytes.length === 32) {
return await subtle.importKey(
"raw",
keyBytes.buffer as ArrayBuffer,
{ name: "X25519" },
true,
[]
)
}
return await subtle.importKey(
"raw",
"spki",
keyBytes.buffer as ArrayBuffer,
{
name: "X25519",
},
{ name: "X25519" },
true,
[]
)
}
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(
"raw",
"pkcs8",
keyBytes.buffer as ArrayBuffer,
{
name: "X25519",
},
{ name: "X25519" },
true,
["deriveBits"]
)
}
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)
}
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)
}
@ -112,24 +126,38 @@ export async function ed25519Verify(
}
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(
"raw",
"spki",
keyBytes.buffer as ArrayBuffer,
{
name: "Ed25519",
},
{ name: "Ed25519" },
true,
["verify"]
)
}
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(
"raw",
"pkcs8",
keyBytes.buffer as ArrayBuffer,
{
name: "Ed25519",
},
{ name: "Ed25519" },
true,
["sign"]
)

View File

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

View File

@ -31,6 +31,7 @@ import {
wsManager,
} from "../websocket"
import { roomService } from "../services"
import { cryptoService, saveDecryptedMessage } from "../crypto"
export default function Chat(): JSX.Element {
const activeRoom = useStore($activeRoom)
@ -47,14 +48,13 @@ export default function Chat(): JSX.Element {
onMount(async () => {
const currentUserId = userId()
console.log("[Chat] onMount - userId:", currentUserId)
if (currentUserId) {
console.log("[Chat] Connecting WebSocket and loading rooms...")
try {
await cryptoService.initialize(currentUserId)
} catch {
}
connectWebSocket()
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()
})
const handleSendMessage = (content: string): void => {
const handleSendMessage = async (content: string): Promise<void> => {
const roomId = activeRoomId()
const room = activeRoom()
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 now = new Date().toISOString()
addMessage(roomId, {
const messageToSend = {
id: tempId,
room_id: roomId,
sender_id: currentUserId,
sender_username: user?.username ?? "me",
content,
status: "sending",
is_encrypted: false,
status: "sending" as const,
is_encrypted: true,
created_at: now,
updated_at: now,
})
}
const sent = wsManager.sendEncryptedMessage(
recipientId,
roomId,
content,
tempId
)
addMessage(roomId, messageToSend)
if (!sent) {
showToast("error", "SEND FAILED", "NOT CONNECTED")
try {
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"
import { isPublicKeyCredential } from "../types/guards"
import { setCurrentUser, logout as storeLogout } from "../stores"
import { cryptoService } from "../crypto"
interface PublicKeyCredentialStatic {
isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean>
@ -131,6 +132,7 @@ export async function register(
})
setCurrentUser(user)
await cryptoService.initialize(user.id)
return user
}
@ -161,6 +163,7 @@ export async function login(username?: string): Promise<User> {
})
setCurrentUser(user)
await cryptoService.initialize(user.id)
return user
}

View File

@ -10,18 +10,21 @@ import {
addRoom,
setRoomMessages,
setHasMore,
$userId,
} from "../stores"
import {
getDecryptedMessages,
getDecryptedMessage,
saveDecryptedMessage,
cryptoService,
} from "../crypto"
export async function loadRooms(userId: string): Promise<Room[]> {
console.log("[RoomService] loadRooms called with userId:", userId)
try {
const response = await api.rooms.list(userId)
console.log("[RoomService] API response:", response)
console.log("[RoomService] rooms count:", response.rooms.length)
setRooms(response.rooms)
return response.rooms
} catch (err) {
console.error("[RoomService] Failed to load rooms:", err)
} catch {
return []
}
}
@ -51,28 +54,85 @@ export async function loadMessages(
offset: number = 0
): Promise<Message[]> {
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 messages: Message[] = response.messages.map((msg) => ({
id: msg.id,
room_id: msg.room_id,
sender_id: msg.sender_id,
sender_username: msg.sender_username,
content: "[Encrypted message]",
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,
}))
const reversedMessages = messages.reverse()
setRoomMessages(roomId, reversedMessages)
const serverMessages = response.messages.reverse()
const newMessages: Message[] = []
const currentUserId = $userId.get()
for (const msg of serverMessages) {
if (localMessageIds.has(msg.id)) {
continue
}
let content = "[Encrypted - from another session]"
const isOwnMessage = msg.sender_id === currentUserId
if (isOwnMessage) {
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)
return reversedMessages
return uniqueMessages
} catch (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 {
identity_key: string
identity_key_ed25519: string
signed_prekey: string
signed_prekey_signature: string
one_time_prekey: string | null
@ -62,6 +63,11 @@ export interface MessageHeader {
previous_chain_length: number
}
export interface FullMessageHeader {
ratchet: MessageHeader
x3dh?: X3DHHeader
}
export interface IdentityKeyPair {
x25519_private: string
x25519_public: string

View File

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

View File

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

View File

@ -108,14 +108,16 @@ class WebSocketManager {
sendEncryptedMessage(
recipientId: string,
roomId: string,
plaintext: string,
encrypted: { ciphertext: string; nonce: string; header: string },
tempId: string
): boolean {
const message: WSOutgoingEncryptedMessage = {
type: "encrypted_message",
recipient_id: recipientId,
room_id: roomId,
plaintext,
ciphertext: encrypted.ciphertext,
nonce: encrypted.nonce,
header: encrypted.header,
temp_id: tempId,
}
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()}})"