fix(encrypted-p2p-chat): apply audit findings — true E2EE, session auth, spec-correct crypto

This commit applies the actionable plan from docs/plans/2026-04-29-encrypted-p2p-chat-audit-fixes.md.

Backend
- Delete server-side X3DH/Double Ratchet (`app/core/encryption/*`); the server is now an opaque relay for client-encrypted messages.
- Drop `private_key` columns from IdentityKey/SignedPrekey/OneTimePrekey; prekey-bundle endpoint serves only public material.
- Remove RatchetState and SkippedMessageKey models and the deprecated server-encryption code path in message_service.
- Fix the broken `not OneTimePrekey.is_used` SQL filter (`Column.is_(False)` instead of Python `not`); repair the silent OPK-replenish amplification by deleting the server-generated path entirely.
- Stop calling `prekey_service.initialize_user_keys` from registration/login. Server never holds private key material again.
- Add session-cookie auth: opaque token issued in Redis on register/auth complete, `current_user` dependency on every protected route, `/auth/me`, `/auth/logout`. WebSocket authenticates via the same cookie.
- Add room membership checks to WS encrypted_message + typing handlers, GET/DELETE /rooms/{id}, GET /rooms/{id}/messages. broadcast_to_room now filters by participants, not presence.
- Add slowapi rate limits to register/auth/search; per-user sliding-window cap on WS messages.
- WebAuthn: replace username with a 64-byte random `webauthn_user_handle` for `user.id`; fix `backup_eligible` to read `credential_backup_eligible`; switch UserVerificationRequirement to REQUIRED; key authentication challenges by challenge bytes (no more "discoverable" collision).
- Implement GET /rooms/{id} and DELETE /rooms/{id} (cascade delete with ownership check) and fix the N+1 in list_rooms by batching User lookups.
- Remove broken/obsolete tests; add regression tests for the OPK filter bug and session-protected endpoints (401 without cookie).
- Sanitize WebSocket error responses; correlation IDs replace stack-trace leaks.

Frontend
- X3DH: prepend the 32-byte 0xff F prefix to the HKDF input per spec section 2.2.
- Double Ratchet: switch KDF_RK to spec form (HKDF salt=root_key, IKM=dh_output) and use 0x01/0x02 byte tags for KDF_CK.
- Replace base64 helpers in `crypto/primitives.ts` with the URL-safe codec from `lib/base64.ts` (single source of truth, no spread-stack hazard).
- Update auth.service to issue a session via cookies (no more dead token store), call `/auth/me` on app boot, clear crypto state on logout. Delete unused `session.store.ts`.
- Update room.service / Chat page to drop client-supplied user_id arguments; the cookie now identifies the caller.
- Better forward-secrecy UX text for messages predating the device.
- Add Vitest with X3DH and Double Ratchet round-trip + tamper-detection tests.

Tooling
- Add `slowapi>=0.1.9` to backend deps; add `vitest` to frontend devDeps with `pnpm test` script.
- justfile: `test-frontend`, `dev-reset`.
- Adjust `.gitignore` so the Python `lib/` rules no longer hide `frontend/src/lib`.

Migration: existing dev volumes hold private-key columns and KDF-incompatible ratchet states; run `just dev-reset` and re-register before hitting the new code path.
This commit is contained in:
CarterPerez-dev 2026-04-29 02:30:10 -04:00
parent ee684e5307
commit 6fd5f3d393
70 changed files with 2879 additions and 2900 deletions

View File

@ -4,31 +4,30 @@
backend/.env
frontend/.env
# Python
# Python build artifacts (paths anchored to backend so frontend/src/lib stays tracked)
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
venv/
.venv/
ENV/
env/
backend/build/
backend/develop-eggs/
backend/downloads/
backend/eggs/
backend/.eggs/
backend/lib/
backend/lib64/
backend/parts/
backend/sdist/
backend/var/
backend/wheels/
backend/*.egg-info/
backend/.installed.cfg
backend/*.egg
backend/venv/
backend/.venv/
backend/ENV/
backend/env/
# Testing
.pytest_cache/

View File

@ -1,6 +1,6 @@
"""
AngelaMos | 2025
Alembic environment configuration for SQLModel migrations
©AngelaMos | 2026
env.py
"""
from logging.config import fileConfig
@ -11,14 +11,11 @@ from sqlmodel import SQLModel
from alembic import context
from app.config import settings
# Import all models so they're registered with SQLModel metadata
from app.models.User import User # noqa: F401
from app.models.Credential import Credential # noqa: F401
from app.models.IdentityKey import IdentityKey # noqa: F401
from app.models.SignedPrekey import SignedPrekey # noqa: F401
from app.models.OneTimePrekey import OneTimePrekey # noqa: F401
from app.models.RatchetState import RatchetState # noqa: F401
from app.models.SkippedMessageKey import SkippedMessageKey # noqa: F401
config = context.config

View File

@ -1,15 +1,30 @@
"""
AngelaMos | 2025
WebAuthn passkey registration and login API
©AngelaMos | 2026
auth.py
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Request, Response, status
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlmodel.ext.asyncio.session import AsyncSession
from app.config import (
RATE_LIMIT_AUTH_BEGIN,
RATE_LIMIT_AUTH_COMPLETE,
RATE_LIMIT_REGISTER_BEGIN,
RATE_LIMIT_USER_SEARCH,
)
from app.core.dependencies import (
current_user,
issue_session,
revoke_session,
session_token_from_cookie,
)
from app.models.Base import get_session
from app.models.User import User
from app.schemas.auth import (
AuthenticationBeginRequest,
AuthenticationCompleteRequest,
@ -27,56 +42,106 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix = "/auth", tags = ["authentication"])
limiter = Limiter(key_func = get_remote_address)
def _user_to_response(user: User) -> UserResponse:
"""
Adapt the ORM User into the API response model
"""
return UserResponse(
id = str(user.id),
username = user.username,
display_name = user.display_name,
is_active = user.is_active,
is_verified = user.is_verified,
created_at = user.created_at.isoformat(),
)
@router.post("/register/begin", status_code = status.HTTP_200_OK)
@limiter.limit(RATE_LIMIT_REGISTER_BEGIN)
async def register_begin(
request: RegistrationBeginRequest,
request: Request,
body: RegistrationBeginRequest,
session: AsyncSession = Depends(get_session),
) -> dict[str,
Any]:
) -> dict[str, Any]:
"""
Begin WebAuthn passkey registration flow
"""
return await auth_service.begin_registration(session, request)
return await auth_service.begin_registration(session, body)
@router.post("/register/complete", status_code = status.HTTP_201_CREATED)
@limiter.limit(RATE_LIMIT_AUTH_COMPLETE)
async def register_complete(
request: RegistrationCompleteRequest,
request: Request,
body: RegistrationCompleteRequest,
response: Response,
session: AsyncSession = Depends(get_session),
) -> UserResponse:
"""
Complete WebAuthn passkey registration
Complete WebAuthn passkey registration and start a session
"""
return await auth_service.complete_registration(session, request, request.username)
user = await auth_service.complete_registration(session, body, body.username)
await issue_session(response, user)
return _user_to_response(user)
@router.post("/authenticate/begin", status_code = status.HTTP_200_OK)
@limiter.limit(RATE_LIMIT_AUTH_BEGIN)
async def authenticate_begin(
request: AuthenticationBeginRequest,
request: Request,
body: AuthenticationBeginRequest,
session: AsyncSession = Depends(get_session),
) -> dict[str,
Any]:
) -> dict[str, Any]:
"""
Begin WebAuthn passkey authentication flow
"""
return await auth_service.begin_authentication(session, request)
return await auth_service.begin_authentication(session, body)
@router.post("/authenticate/complete", status_code = status.HTTP_200_OK)
@limiter.limit(RATE_LIMIT_AUTH_COMPLETE)
async def authenticate_complete(
request: AuthenticationCompleteRequest,
request: Request,
body: AuthenticationCompleteRequest,
response: Response,
session: AsyncSession = Depends(get_session),
) -> UserResponse:
"""
Complete WebAuthn passkey authentication
Complete WebAuthn passkey authentication and start a session
"""
return await auth_service.complete_authentication(session, request)
user = await auth_service.complete_authentication(session, body)
await issue_session(response, user)
return _user_to_response(user)
@router.get("/me", status_code = status.HTTP_200_OK)
async def me(user: User = Depends(current_user)) -> UserResponse:
"""
Return the user bound to the current session
"""
return _user_to_response(user)
@router.post("/logout", status_code = status.HTTP_204_NO_CONTENT)
async def logout(
response: Response,
token: str | None = Depends(session_token_from_cookie),
) -> None:
"""
Invalidate the current session and clear its cookie
"""
await revoke_session(response, token)
@router.post("/users/search", status_code = status.HTTP_200_OK)
@limiter.limit(RATE_LIMIT_USER_SEARCH)
async def search_users(
request: UserSearchRequest,
request: Request,
body: UserSearchRequest,
user: User = Depends(current_user),
session: AsyncSession = Depends(get_session),
) -> UserSearchResponse:
"""
@ -84,20 +149,11 @@ async def search_users(
"""
users = await auth_service.search_users(
session,
request.query,
request.limit,
body.query,
body.limit,
exclude_user_id = user.id,
)
return UserSearchResponse(
users = [
UserResponse(
id = str(user.id),
username = user.username,
display_name = user.display_name,
is_active = user.is_active,
is_verified = user.is_verified,
created_at = user.created_at.isoformat(),
)
for user in users
]
users = [_user_to_response(u) for u in users]
)

View File

@ -1,6 +1,6 @@
"""
AngelaMos | 2025
Encryption endpoints for X3DH prekey bundles
©AngelaMos | 2026
encryption.py
"""
import logging
@ -11,8 +11,7 @@ from pydantic import BaseModel
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.Base import get_session
from app.services.prekey_service import prekey_service
from app.core.encryption.x3dh_manager import PreKeyBundle
from app.services.prekey_service import PreKeyBundle, prekey_service
logger = logging.getLogger(__name__)
@ -21,6 +20,9 @@ router = APIRouter(prefix = "/encryption", tags = ["encryption"])
class ClientKeysUpload(BaseModel):
"""
Request body for uploading client generated public keys
"""
identity_key: str
identity_key_ed25519: str
signed_prekey: str
@ -31,52 +33,27 @@ class ClientKeysUpload(BaseModel):
@router.get(
"/prekey-bundle/{user_id}",
status_code = status.HTTP_200_OK,
response_model = PreKeyBundle
response_model = PreKeyBundle,
)
async def get_prekey_bundle(
user_id: UUID,
session: AsyncSession = Depends(get_session),
) -> PreKeyBundle:
"""
Retrieves prekey bundle for initiating X3DH key exchange with a user
Returns a prekey bundle for initiating X3DH with the target user
"""
bundle = await prekey_service.get_prekey_bundle(session, user_id)
unused_count = await prekey_service.get_unused_opk_count(session, user_id)
if unused_count < 20:
logger.info("User %s has %s OPKs, replenishing", user_id, unused_count)
await prekey_service.replenish_one_time_prekeys(session, user_id, 100)
return bundle
@router.post("/initialize-keys/{user_id}", status_code = status.HTTP_201_CREATED)
async def initialize_keys(
user_id: UUID,
session: AsyncSession = Depends(get_session),
) -> 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)
return {
"status": "success",
"message": f"Initialized encryption keys for user {user_id}"
}
@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]:
) -> dict[str, str]:
"""
Stores client-generated public keys for E2E encryption
Stores client generated public keys for E2E encryption
"""
await prekey_service.store_client_keys(
session,
@ -85,41 +62,9 @@ async def upload_client_keys(
keys.identity_key_ed25519,
keys.signed_prekey,
keys.signed_prekey_signature,
keys.one_time_prekeys
keys.one_time_prekeys,
)
return {
"status": "success",
"message": f"Stored client keys for user {user_id}"
"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,
session: AsyncSession = Depends(get_session),
) -> dict[str,
str]:
"""
Manually rotates signed prekey for a user
"""
await prekey_service.rotate_signed_prekey(session, user_id)
return {
"status": "success",
"message": f"Rotated signed prekey for user {user_id}"
}
@router.get("/opk-count/{user_id}", status_code = status.HTTP_200_OK)
async def get_opk_count(
user_id: UUID,
session: AsyncSession = Depends(get_session),
) -> dict[str,
int]:
"""
Returns count of unused one time prekeys for a user
"""
count = await prekey_service.get_unused_opk_count(session, user_id)
return {"unused_opks": count}

View File

@ -1,11 +1,12 @@
"""
AngelaMos | 2025
Rooms API for creating and managing chat rooms
©AngelaMos | 2026
rooms.py
"""
import logging
from uuid import UUID
from collections.abc import Iterable
from datetime import UTC, datetime
from uuid import UUID
from fastapi import (
APIRouter,
@ -13,8 +14,19 @@ from fastapi import (
HTTPException,
status,
)
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.config import (
DEFAULT_MESSAGE_LIMIT,
MAX_MESSAGE_LIMIT,
)
from app.core.dependencies import current_user
from app.core.enums import RoomType
from app.core.surreal_manager import surreal_db
from app.core.websocket_manager import connection_manager
from app.models.Base import get_session
from app.models.User import User
from app.schemas.rooms import (
CreateRoomRequest,
ParticipantResponse,
@ -22,11 +34,6 @@ from app.schemas.rooms import (
RoomListResponse,
)
from app.schemas.websocket import RoomCreatedWS
from app.core.enums import RoomType
from app.models.Base import get_session
from app.core.surreal_manager import surreal_db
from app.core.websocket_manager import connection_manager
from app.services.auth_service import auth_service
logger = logging.getLogger(__name__)
@ -35,64 +42,113 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix = "/rooms", tags = ["rooms"])
async def _hydrate_users(
session: AsyncSession,
user_ids: Iterable[str],
) -> dict[str, User]:
"""
Bulk fetch users by id and return a mapping keyed by string uuid
"""
unique_ids: list[UUID] = []
seen: set[str] = set()
for raw in user_ids:
if not raw or raw in seen:
continue
try:
unique_ids.append(UUID(raw))
seen.add(raw)
except ValueError:
continue
if not unique_ids:
return {}
statement = select(User).where(User.id.in_(unique_ids))
result = await session.execute(statement)
rows = result.scalars().all()
return {str(u.id): u for u in rows}
def _build_participants(
raw_participants: list[dict],
users_by_id: dict[str, User],
) -> list[ParticipantResponse]:
"""
Translate SurrealDB participant rows into API response models
"""
out: list[ParticipantResponse] = []
for row in raw_participants:
uid = row.get("user_id")
if not uid:
continue
user = users_by_id.get(str(uid))
if user is None:
continue
out.append(
ParticipantResponse(
user_id = str(user.id),
username = user.username,
display_name = user.display_name,
role = row.get("role", "member"),
joined_at = str(row.get("joined_at", "")),
)
)
return out
@router.post("", status_code = status.HTTP_201_CREATED)
async def create_room(
request: CreateRoomRequest,
body: CreateRoomRequest,
user: User = Depends(current_user),
session: AsyncSession = Depends(get_session),
) -> RoomAPIResponse:
"""
Create a new chat room
Create a new chat room with the current user as owner
"""
creator = await auth_service.get_user_by_id(
session,
UUID(request.creator_id),
)
if not creator:
try:
participant_uuid = UUID(body.participant_id)
except ValueError as exc:
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = "Creator not found",
status_code = status.HTTP_400_BAD_REQUEST,
detail = "participant_id must be a valid UUID",
) from exc
if participant_uuid == user.id:
raise HTTPException(
status_code = status.HTTP_400_BAD_REQUEST,
detail = "Cannot start a conversation with yourself",
)
participant = await auth_service.get_user_by_id(
session,
UUID(request.participant_id),
)
if not participant:
statement = select(User).where(User.id == participant_uuid)
result = await session.execute(statement)
participant = result.scalar_one_or_none()
if participant is None:
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = "Participant not found",
)
now = datetime.now(UTC)
room_data = {
"name": None,
"room_type": request.room_type.value,
"created_by": request.creator_id,
"room_type": body.room_type.value,
"created_by": str(user.id),
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
"is_ephemeral": request.room_type == RoomType.EPHEMERAL,
"is_ephemeral": body.room_type == RoomType.EPHEMERAL,
}
room = await surreal_db.create_room(room_data)
await surreal_db.add_room_participant(room.id, request.creator_id, "owner")
await surreal_db.add_room_participant(room.id, request.participant_id, "member")
logger.info(
"Created room %s with creator %s and participant %s",
room.id,
request.creator_id,
request.participant_id,
await surreal_db.add_room_participant(room.id, str(user.id), "owner")
await surreal_db.add_room_participant(
room.id, str(participant.id), "member"
)
participants_list = [
ParticipantResponse(
user_id = str(creator.id),
username = creator.username,
display_name = creator.display_name,
user_id = str(user.id),
username = user.username,
display_name = user.display_name,
role = "owner",
joined_at = now.isoformat(),
),
@ -102,22 +158,28 @@ async def create_room(
display_name = participant.display_name,
role = "member",
joined_at = now.isoformat(),
)
),
]
room_ws_notification = RoomCreatedWS(
notification = RoomCreatedWS(
room_id = room.id,
room_type = room.room_type.value,
name = creator.display_name,
name = user.display_name,
participants = [p.model_dump() for p in participants_list],
is_encrypted = True,
created_at = room.created_at.isoformat(),
updated_at = room.updated_at.isoformat(),
)
await connection_manager.send_message(
UUID(request.participant_id),
room_ws_notification.model_dump(mode = "json"),
participant.id,
notification.model_dump(mode = "json"),
)
logger.info(
"Created room %s with creator %s and participant %s",
room.id,
user.id,
participant.id,
)
return RoomAPIResponse(
@ -134,55 +196,46 @@ async def create_room(
@router.get("", status_code = status.HTTP_200_OK)
async def list_rooms(
user_id: str,
user: User = Depends(current_user),
session: AsyncSession = Depends(get_session),
) -> RoomListResponse:
"""
List all rooms for the specified user
List rooms in which the current user is a participant
"""
user = await auth_service.get_user_by_id(session, UUID(user_id))
room_data_list = await surreal_db.get_rooms_for_user(str(user.id))
if not user:
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = "User not found",
)
room_data_list = await surreal_db.get_rooms_for_user(user_id)
rooms: list[RoomAPIResponse] = []
all_participant_rows: list[list[dict]] = []
user_ids: list[str] = []
for room_data in room_data_list:
if not room_data:
all_participant_rows.append([])
continue
room_id = str(room_data.get("id", ""))
participants_data = await surreal_db.get_room_participants(room_id)
rows = await surreal_db.get_room_participants(room_id)
all_participant_rows.append(rows)
for row in rows:
uid = row.get("user_id")
if uid:
user_ids.append(str(uid))
participants: list[ParticipantResponse] = []
for p_data in participants_data:
p_user_id = p_data.get("user_id")
if not p_user_id:
continue
p_user = await auth_service.get_user_by_id(session, UUID(p_user_id))
if p_user:
participants.append(
ParticipantResponse(
user_id = str(p_user.id),
username = p_user.username,
display_name = p_user.display_name,
role = p_data.get("role", "member"),
joined_at = str(p_data.get("joined_at", "")),
)
)
users_by_id = await _hydrate_users(session, user_ids)
rooms: list[RoomAPIResponse] = []
for room_data, raw_participants in zip(
room_data_list, all_participant_rows, strict = False
):
if not room_data:
continue
room_id = str(room_data.get("id", ""))
participants = _build_participants(raw_participants, users_by_id)
other_participant = next(
(p for p in participants if p.user_id != user_id),
None
(p for p in participants if p.user_id != str(user.id)),
None,
)
room_name = (
other_participant.display_name if other_participant else None
)
room_name = other_participant.display_name if other_participant else None
rooms.append(
RoomAPIResponse(
@ -200,39 +253,120 @@ async def list_rooms(
return RoomListResponse(rooms = rooms)
async def _require_membership(
room_id: str,
user: User,
) -> None:
"""
Ensure the current user is a member of the room
"""
is_member = await surreal_db.is_room_participant(room_id, str(user.id))
if not is_member:
raise HTTPException(
status_code = status.HTTP_403_FORBIDDEN,
detail = "Not a member of this room",
)
@router.get("/{room_id}", status_code = status.HTTP_200_OK)
async def get_room(room_id: str) -> RoomAPIResponse:
async def get_room(
room_id: str,
user: User = Depends(current_user),
session: AsyncSession = Depends(get_session),
) -> RoomAPIResponse:
"""
Get a specific room
Return a single room the current user belongs to
"""
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = "Room not found",
await _require_membership(room_id, user)
room_data = await surreal_db.get_room(room_id)
if not room_data:
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = "Room not found",
)
raw_participants = await surreal_db.get_room_participants(room_id)
users_by_id = await _hydrate_users(
session, (row.get("user_id") for row in raw_participants)
)
participants = _build_participants(raw_participants, users_by_id)
other_participant = next(
(p for p in participants if p.user_id != str(user.id)),
None,
)
room_name = other_participant.display_name if other_participant else None
created_at = room_data.get("created_at")
updated_at = room_data.get("updated_at")
return RoomAPIResponse(
id = str(room_data.get("id", room_id)),
type = RoomType(room_data.get("room_type", "direct")),
name = room_name,
participants = participants,
unread_count = 0,
is_encrypted = True,
created_at = (
created_at.isoformat()
if hasattr(created_at, "isoformat")
else str(created_at or "")
),
updated_at = (
updated_at.isoformat()
if hasattr(updated_at, "isoformat")
else str(updated_at or "")
),
)
@router.get("/{room_id}/messages", status_code = status.HTTP_200_OK)
async def get_room_messages(
room_id: str,
limit: int = 50,
user: User = Depends(current_user),
limit: int = DEFAULT_MESSAGE_LIMIT,
offset: int = 0,
) -> dict:
"""
Get messages for a specific room
Return ciphertext messages for a room visible to the current user
"""
messages = await surreal_db.get_room_messages(room_id, limit, offset)
await _require_membership(room_id, user)
bounded_limit = max(1, min(limit, MAX_MESSAGE_LIMIT))
messages = await surreal_db.get_room_messages(
room_id, bounded_limit, offset
)
return {
"messages": [msg.model_dump(mode = "json") for msg in messages],
"has_more": len(messages) == limit
"has_more": len(messages) == bounded_limit,
}
@router.delete("/{room_id}", status_code = status.HTTP_204_NO_CONTENT)
async def delete_room(room_id: str) -> None:
async def delete_room(
room_id: str,
user: User = Depends(current_user),
) -> None:
"""
Delete a room
Delete a room owned by the current user along with its messages
"""
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = "Room not found",
raw_participants = await surreal_db.get_room_participants(room_id)
if not raw_participants:
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = "Room not found",
)
is_owner = any(
str(row.get("user_id")) == str(user.id)
and row.get("role") == "owner"
for row in raw_participants
)
if not is_owner:
raise HTTPException(
status_code = status.HTTP_403_FORBIDDEN,
detail = "Only the room owner can delete it",
)
await surreal_db.delete_room(room_id)
logger.info("Deleted room %s by user %s", room_id, user.id)

View File

@ -1,18 +1,21 @@
"""
AngelaMos | 2025
WebSocket endpoints for real time chat communication
©AngelaMos | 2026
websocket.py
"""
import json
import logging
import secrets
from uuid import UUID
from fastapi import (
APIRouter,
WebSocket,
WebSocketDisconnect,
Query,
)
from app.config import SESSION_COOKIE_NAME
from app.core.redis_manager import redis_manager
from app.core.websocket_manager import connection_manager
from app.services.websocket_service import websocket_service
@ -22,23 +25,37 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix = "/ws", tags = ["websocket"])
@router.websocket("")
async def websocket_endpoint(
websocket: WebSocket,
user_id: str = Query(...),
) -> None:
async def _resolve_user(websocket: WebSocket) -> UUID | None:
"""
Main WebSocket endpoint for real time messaging
Resolve the authenticated user from the session cookie or close the socket
"""
token = websocket.cookies.get(SESSION_COOKIE_NAME)
if not token:
await websocket.close(code = 4401, reason = "Not authenticated")
return None
user_id_str = await redis_manager.get_session_user(token)
if user_id_str is None:
await websocket.close(code = 4401, reason = "Session expired")
return None
try:
user_uuid = UUID(user_id)
return UUID(user_id_str)
except ValueError:
logger.error("Invalid user_id format: %s", user_id)
await websocket.close(code = 1008, reason = "Invalid user ID")
await websocket.close(code = 4401, reason = "Invalid session")
return None
@router.websocket("")
async def websocket_endpoint(websocket: WebSocket) -> None:
"""
Main WebSocket endpoint authenticated via the session cookie
"""
user_uuid = await _resolve_user(websocket)
if user_uuid is None:
return
connected = await connection_manager.connect(websocket, user_uuid)
if not connected:
return
@ -51,34 +68,40 @@ async def websocket_endpoint(
await websocket_service.route_message(
websocket,
user_uuid,
message
message,
)
except json.JSONDecodeError:
logger.error(
"Invalid JSON from user %s: %s",
logger.warning(
"Invalid JSON from user %s",
user_uuid,
data[: 100]
)
await websocket.send_json(
{
"type": "error",
"error_code": "invalid_json",
"error_message": "Invalid JSON format"
"error_message": "Invalid JSON format",
}
)
except Exception as e:
logger.error("Error handling message from %s: %s", user_uuid, e)
except Exception as exc:
error_id = secrets.token_hex(8)
logger.error(
"[%s] Error handling message from %s: %s",
error_id,
user_uuid,
exc,
)
await websocket.send_json(
{
"type": "error",
"error_code": "processing_error",
"error_message": str(e)
"error_message": "Internal error",
"error_id": error_id,
}
)
except WebSocketDisconnect:
logger.info("WebSocket disconnected for user %s", user_uuid)
except Exception as e:
logger.error("WebSocket error for user %s: %s", user_uuid, e)
except Exception as exc:
logger.error("WebSocket error for user %s: %s", user_uuid, exc)
finally:
await connection_manager.disconnect(websocket, user_uuid)

View File

@ -1,6 +1,6 @@
"""
AngelaMos | 2025
All environment variables and constants are centralized here
©AngelaMos | 2026
config.py
"""
from typing import Literal
@ -24,7 +24,6 @@ USERNAME_MAX_LENGTH = 50
DISPLAY_NAME_MIN_LENGTH = 1
DISPLAY_NAME_MAX_LENGTH = 100
DEVICE_NAME_MAX_LENGTH = 100
PREKEY_MAX_LENGTH = 500
# User search
USER_SEARCH_MIN_LENGTH = 2
@ -54,27 +53,14 @@ WS_MESSAGE_TYPE_PRESENCE = "presence"
WS_MESSAGE_TYPE_RECEIPT = "receipt"
WS_MESSAGE_TYPE_ERROR = "error"
# Encryption key field lengths
# Public key max base64 lengths stored server-side
IDENTITY_KEY_LENGTH = 64
SIGNED_PREKEY_LENGTH = 64
ONE_TIME_PREKEY_LENGTH = 64
SIGNATURE_LENGTH = 128
RATCHET_STATE_MAX_LENGTH = 100000
# Encryption constants
X25519_KEY_SIZE = 32
ED25519_KEY_SIZE = 32
ED25519_SIGNATURE_SIZE = 64
AES_GCM_KEY_SIZE = 32
AES_GCM_NONCE_SIZE = 12
HKDF_OUTPUT_SIZE = 32
# Double Ratchet limits
MAX_SKIP_MESSAGE_KEYS = 1000
MAX_CACHED_MESSAGE_KEYS = 2000
DEFAULT_ONE_TIME_PREKEY_COUNT = 100
# Client signed prekey rotation cadence
SIGNED_PREKEY_ROTATION_HOURS = 48
SIGNED_PREKEY_RETENTION_DAYS = 7
# Server defaults
DEFAULT_HOST = "0.0.0.0"
@ -84,6 +70,21 @@ DEFAULT_PORT = 8000
WEBAUTHN_CHALLENGE_TTL_SECONDS = 600
WEBAUTHN_CHALLENGE_BYTES = 32
# Session settings
SESSION_TTL_SECONDS = 60 * 60 * 24
SESSION_COOKIE_NAME = "chat_session"
SESSION_TOKEN_BYTES = 32
# WebAuthn user handle
WEBAUTHN_USER_HANDLE_BYTES = 64
# Rate limit defaults (used by slowapi)
RATE_LIMIT_REGISTER_BEGIN = "5/minute"
RATE_LIMIT_AUTH_BEGIN = "10/minute"
RATE_LIMIT_AUTH_COMPLETE = "10/minute"
RATE_LIMIT_USER_SEARCH = "30/minute"
RATE_LIMIT_WS_MESSAGE = 60
# Application metadata
APP_VERSION = "1.0.0"
APP_STATUS = "running"

View File

@ -0,0 +1,107 @@
"""
©AngelaMos | 2026
dependencies.py
"""
import logging
import secrets
from uuid import UUID
from fastapi import Cookie, Depends, HTTPException, Response, status
from sqlmodel.ext.asyncio.session import AsyncSession
from app.config import (
SESSION_COOKIE_NAME,
SESSION_TOKEN_BYTES,
SESSION_TTL_SECONDS,
settings,
)
from app.core.redis_manager import redis_manager
from app.models.Base import get_session
from app.models.User import User
from app.services.auth_service import auth_service
logger = logging.getLogger(__name__)
async def issue_session(response: Response, user: User) -> str:
"""
Create a session token, persist it in Redis, and attach the cookie
"""
token = secrets.token_urlsafe(SESSION_TOKEN_BYTES)
await redis_manager.create_session(
token = token,
user_id = str(user.id),
ttl = SESSION_TTL_SECONDS,
)
response.set_cookie(
key = SESSION_COOKIE_NAME,
value = token,
max_age = SESSION_TTL_SECONDS,
httponly = True,
secure = settings.is_production,
samesite = "lax",
path = "/",
)
return token
async def revoke_session(response: Response, token: str | None) -> None:
"""
Drop the session token from Redis and clear the cookie
"""
if token:
await redis_manager.delete_session(token)
response.delete_cookie(
key = SESSION_COOKIE_NAME,
path = "/",
)
async def current_user(
chat_session: str | None = Cookie(default = None, alias = SESSION_COOKIE_NAME),
session: AsyncSession = Depends(get_session),
) -> User:
"""
Resolve the user bound to the current session cookie or 401
"""
if not chat_session:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Not authenticated",
)
user_id_str = await redis_manager.get_session_user(chat_session)
if user_id_str is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Session expired or invalid",
)
try:
user_id = UUID(user_id_str)
except ValueError as exc:
logger.error("Corrupt session payload for token: %s", exc)
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Session expired or invalid",
) from exc
user = await auth_service.get_user_by_id(session = session, user_id = user_id)
if user is None or not user.is_active:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Session expired or invalid",
)
return user
async def session_token_from_cookie(
chat_session: str | None = Cookie(default = None, alias = SESSION_COOKIE_NAME),
) -> str | None:
"""
Pass-through dependency to read the session cookie value
"""
return chat_session

View File

@ -1,419 +0,0 @@
"""
AngelaMos | 2025
Double Ratchet algorithm implementation for end to end encryption
"""
import os
import logging
from dataclasses import (
field,
dataclass,
)
from cryptography.hazmat.primitives.asymmetric.x25519 import (
X25519PrivateKey,
X25519PublicKey,
)
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hmac
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import hashes, serialization
from app.config import (
AES_GCM_NONCE_SIZE,
HKDF_OUTPUT_SIZE,
MAX_CACHED_MESSAGE_KEYS,
MAX_SKIP_MESSAGE_KEYS,
X25519_KEY_SIZE,
)
logger = logging.getLogger(__name__)
@dataclass
class DoubleRatchetState:
"""
Complete state for Double Ratchet algorithm per conversation
"""
root_key: bytes
sending_chain_key: bytes
receiving_chain_key: bytes
dh_private_key: X25519PrivateKey | None
dh_peer_public_key: bytes | None
sending_message_number: int = 0
receiving_message_number: int = 0
previous_sending_chain_length: int = 0
skipped_message_keys: dict[tuple[bytes,
int],
bytes] = field(default_factory = dict)
@dataclass
class EncryptedMessage:
"""
Encrypted message with header and metadata
"""
ciphertext: bytes
nonce: bytes
dh_public_key: bytes
message_number: int
previous_chain_length: int
class DoubleRatchet:
"""
Implementation of Signal Protocol Double Ratchet algorithm
"""
def __init__(
self,
max_skip: int = MAX_SKIP_MESSAGE_KEYS,
max_cache: int = MAX_CACHED_MESSAGE_KEYS
):
"""
Initialize Double Ratchet with security limits
"""
self.max_skip = max_skip
self.max_cache = max_cache
def _kdf_rk(self, root_key: bytes, dh_output: bytes) -> tuple[bytes, bytes]:
"""
Derives new root key and chain key from DH output
"""
hkdf = HKDF(
algorithm = hashes.SHA256(),
length = HKDF_OUTPUT_SIZE * 2,
salt = root_key,
info = b'',
)
output = hkdf.derive(dh_output)
new_root_key = output[: HKDF_OUTPUT_SIZE]
new_chain_key = output[HKDF_OUTPUT_SIZE :]
logger.debug("Derived new root key and chain key")
return new_root_key, new_chain_key
def _kdf_ck(self, chain_key: bytes) -> tuple[bytes, bytes]:
"""
Derives next chain key and message key from current chain key
"""
h_chain = hmac.HMAC(chain_key, hashes.SHA256())
h_chain.update(b'\x01')
next_chain_key = h_chain.finalize()
h_message = hmac.HMAC(chain_key, hashes.SHA256())
h_message.update(b'\x02')
message_key = h_message.finalize()
logger.debug("Derived next chain key and message key")
return next_chain_key, message_key
def _encrypt_with_message_key(
self,
message_key: bytes,
plaintext: bytes,
associated_data: bytes
) -> tuple[bytes,
bytes]:
"""
Encrypts plaintext using AES-256-GCM with message key
"""
aesgcm = AESGCM(message_key)
nonce = os.urandom(AES_GCM_NONCE_SIZE)
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data)
logger.debug(
"Encrypted %s bytes to %s bytes",
len(plaintext),
len(ciphertext)
)
return nonce, ciphertext
def _decrypt_with_message_key(
self,
message_key: bytes,
nonce: bytes,
ciphertext: bytes,
associated_data: bytes
) -> bytes:
"""
Decrypts ciphertext using AES-256-GCM with message key
"""
aesgcm = AESGCM(message_key)
try:
plaintext = aesgcm.decrypt(nonce, ciphertext, associated_data)
logger.debug(
"Decrypted %s bytes to %s bytes",
len(ciphertext),
len(plaintext)
)
return plaintext
except InvalidTag as e:
logger.error("Message authentication failed")
raise ValueError("Message tampered or corrupted") from e
def _dh_ratchet_send(self, state: DoubleRatchetState) -> None:
"""
Performs DH ratchet step when sending
"""
state.dh_private_key = X25519PrivateKey.generate()
state.previous_sending_chain_length = state.sending_message_number
state.sending_message_number = 0
state.receiving_message_number = 0
if state.dh_peer_public_key:
peer_public = X25519PublicKey.from_public_bytes(
state.dh_peer_public_key
)
dh_output = state.dh_private_key.exchange(peer_public)
state.root_key, state.sending_chain_key = self._kdf_rk(
state.root_key,
dh_output
)
logger.debug("DH ratchet step completed (send)")
def _dh_ratchet_receive(
self,
state: DoubleRatchetState,
peer_public_key: bytes
) -> None:
"""
Performs DH ratchet step when receiving
"""
state.previous_sending_chain_length = state.sending_message_number
state.sending_message_number = 0
state.receiving_message_number = 0
state.dh_peer_public_key = peer_public_key
if state.dh_private_key:
peer_public = X25519PublicKey.from_public_bytes(peer_public_key)
dh_output = state.dh_private_key.exchange(peer_public)
state.root_key, state.receiving_chain_key = self._kdf_rk(
state.root_key,
dh_output
)
state.dh_private_key = X25519PrivateKey.generate()
if state.dh_peer_public_key:
peer_public = X25519PublicKey.from_public_bytes(
state.dh_peer_public_key
)
dh_output = state.dh_private_key.exchange(peer_public)
state.root_key, state.sending_chain_key = self._kdf_rk(
state.root_key,
dh_output
)
logger.debug("DH ratchet step completed (receive)")
def _store_skipped_message_keys(
self,
state: DoubleRatchetState,
until_message_number: int,
dh_public_key: bytes
) -> None:
"""
Stores skipped message keys for out of order delivery
"""
num_to_skip = until_message_number - state.receiving_message_number
if num_to_skip > self.max_skip:
raise ValueError(
f"Cannot skip {num_to_skip} messages "
f"(MAX_SKIP={self.max_skip})"
)
if len(state.skipped_message_keys) + num_to_skip > self.max_cache:
logger.warning("Skipped message key cache full, evicting oldest keys")
self._evict_oldest_skipped_keys(state, num_to_skip)
chain_key = state.receiving_chain_key
for msg_num in range(state.receiving_message_number,
until_message_number):
chain_key, message_key = self._kdf_ck(chain_key)
state.skipped_message_keys[(dh_public_key, msg_num)] = message_key
state.receiving_chain_key = chain_key
logger.debug("Stored %s skipped message keys", num_to_skip)
def _evict_oldest_skipped_keys(
self,
state: DoubleRatchetState,
count: int
) -> None:
"""
Evicts oldest skipped message keys to make room
"""
keys_to_remove = list(state.skipped_message_keys.keys())[: count]
for key in keys_to_remove:
del state.skipped_message_keys[key]
logger.debug("Evicted %s skipped keys", len(keys_to_remove))
def _try_skipped_message_key(
self,
state: DoubleRatchetState,
dh_public_key: bytes,
message_number: int
) -> bytes | None:
"""
Attempts to retrieve skipped message key
"""
key = (dh_public_key, message_number)
message_key = state.skipped_message_keys.pop(key, None)
if message_key:
logger.debug(
"Retrieved skipped message key for msg %s",
message_number
)
return message_key
def initialize_sender(
self,
shared_key: bytes,
peer_public_key: bytes
) -> DoubleRatchetState:
"""
Initializes Double Ratchet as sender after X3DH
"""
dh_private = X25519PrivateKey.generate()
peer_public = X25519PublicKey.from_public_bytes(peer_public_key)
dh_output = dh_private.exchange(peer_public)
root_key, sending_chain_key = self._kdf_rk(shared_key, dh_output)
state = DoubleRatchetState(
root_key = root_key,
sending_chain_key = sending_chain_key,
receiving_chain_key = b'\x00' * HKDF_OUTPUT_SIZE,
dh_private_key = dh_private,
dh_peer_public_key = peer_public_key
)
logger.info("Double Ratchet initialized as sender")
return state
def initialize_receiver(
self,
shared_key: bytes,
own_private_key: X25519PrivateKey
) -> DoubleRatchetState:
"""
Initializes Double Ratchet as receiver after X3DH
"""
state = DoubleRatchetState(
root_key = shared_key,
sending_chain_key = b'\x00' * HKDF_OUTPUT_SIZE,
receiving_chain_key = b'\x00' * HKDF_OUTPUT_SIZE,
dh_private_key = own_private_key,
dh_peer_public_key = None
)
logger.info("Double Ratchet initialized as receiver")
return state
def encrypt_message(
self,
state: DoubleRatchetState,
plaintext: bytes,
associated_data: bytes
) -> EncryptedMessage:
"""
Encrypts message and advances sending ratchet
"""
state.sending_chain_key, message_key = self._kdf_ck(
state.sending_chain_key
)
nonce, ciphertext = self._encrypt_with_message_key(
message_key,
plaintext,
associated_data
)
if state.dh_private_key:
dh_public = state.dh_private_key.public_key()
dh_public_bytes = dh_public.public_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PublicFormat.Raw
)
else:
dh_public_bytes = b'\x00' * X25519_KEY_SIZE
encrypted_msg = EncryptedMessage(
ciphertext = ciphertext,
nonce = nonce,
dh_public_key = dh_public_bytes,
message_number = state.sending_message_number,
previous_chain_length = state.previous_sending_chain_length
)
state.sending_message_number += 1
logger.info("Encrypted message #%s", encrypted_msg.message_number)
return encrypted_msg
def decrypt_message(
self,
state: DoubleRatchetState,
encrypted_msg: EncryptedMessage,
associated_data: bytes
) -> bytes:
"""
Decrypts message and advances receiving ratchet
"""
skipped_key = self._try_skipped_message_key(
state,
encrypted_msg.dh_public_key,
encrypted_msg.message_number
)
if skipped_key:
return self._decrypt_with_message_key(
skipped_key,
encrypted_msg.nonce,
encrypted_msg.ciphertext,
associated_data
)
if encrypted_msg.dh_public_key != state.dh_peer_public_key:
if state.dh_peer_public_key:
self._store_skipped_message_keys(
state,
encrypted_msg.previous_chain_length,
state.dh_peer_public_key
)
self._dh_ratchet_receive(state, encrypted_msg.dh_public_key)
if encrypted_msg.message_number > state.receiving_message_number:
self._store_skipped_message_keys(
state,
encrypted_msg.message_number,
encrypted_msg.dh_public_key
)
state.receiving_chain_key, message_key = self._kdf_ck(
state.receiving_chain_key
)
state.receiving_message_number += 1
plaintext = self._decrypt_with_message_key(
message_key,
encrypted_msg.nonce,
encrypted_msg.ciphertext,
associated_data
)
logger.info("Decrypted message #%s", encrypted_msg.message_number)
return plaintext
double_ratchet = DoubleRatchet()

View File

@ -1,353 +0,0 @@
"""
AngelaMos | 2025
X3DH key exchange manager for async initial key agreement
"""
import logging
from dataclasses import dataclass
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric.x25519 import (
X25519PrivateKey,
X25519PublicKey,
)
from cryptography.exceptions import InvalidSignature
from webauthn.helpers import (
bytes_to_base64url,
base64url_to_bytes,
)
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from app.config import (
X25519_KEY_SIZE,
)
logger = logging.getLogger(__name__)
@dataclass
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
@dataclass
class X3DHResult:
"""
Result of X3DH key exchange containing shared key and metadata
"""
shared_key: bytes
associated_data: bytes
ephemeral_public_key: str
used_one_time_prekey: bool
class X3DHManager:
"""
Manages X3DH key exchange protocol for async initial key agreement
"""
def generate_identity_keypair_x25519(self) -> tuple[str, str]:
"""
Generates X25519 identity keypair for DH operations
"""
private_key = X25519PrivateKey.generate()
public_key = private_key.public_key()
private_bytes = private_key.private_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PrivateFormat.Raw,
encryption_algorithm = serialization.NoEncryption()
)
public_bytes = public_key.public_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PublicFormat.Raw
)
logger.debug(
"Generated X25519 identity keypair: %s private, %s public bytes",
len(private_bytes),
len(public_bytes)
)
return (
bytes_to_base64url(private_bytes),
bytes_to_base64url(public_bytes)
)
def generate_identity_keypair_ed25519(self) -> tuple[str, str]:
"""
Generates Ed25519 identity keypair for signing prekeys
"""
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
private_bytes = private_key.private_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PrivateFormat.Raw,
encryption_algorithm = serialization.NoEncryption()
)
public_bytes = public_key.public_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PublicFormat.Raw
)
logger.debug(
"Generated Ed25519 signing keypair: %s private, %s public bytes",
len(private_bytes),
len(public_bytes)
)
return (
bytes_to_base64url(private_bytes),
bytes_to_base64url(public_bytes)
)
def generate_signed_prekey(self,
identity_private_key_ed25519: str) -> tuple[str,
str,
str]:
"""
Generates signed prekey with signature from Ed25519 identity key
"""
spk_private = X25519PrivateKey.generate()
spk_public = spk_private.public_key()
spk_private_bytes = spk_private.private_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PrivateFormat.Raw,
encryption_algorithm = serialization.NoEncryption()
)
spk_public_bytes = spk_public.public_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PublicFormat.Raw
)
identity_private_bytes = base64url_to_bytes(identity_private_key_ed25519)
identity_private = Ed25519PrivateKey.from_private_bytes(
identity_private_bytes
)
signature = identity_private.sign(spk_public_bytes)
logger.debug(
"Generated signed prekey with %s byte signature",
len(signature)
)
return (
bytes_to_base64url(spk_private_bytes),
bytes_to_base64url(spk_public_bytes),
bytes_to_base64url(signature)
)
def generate_one_time_prekey(self) -> tuple[str, str]:
"""
Generates single-use one-time prekey
"""
opk_private = X25519PrivateKey.generate()
opk_public = opk_private.public_key()
opk_private_bytes = opk_private.private_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PrivateFormat.Raw,
encryption_algorithm = serialization.NoEncryption()
)
opk_public_bytes = opk_public.public_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PublicFormat.Raw
)
return (
bytes_to_base64url(opk_private_bytes),
bytes_to_base64url(opk_public_bytes)
)
def verify_signed_prekey(
self,
signed_prekey_public: str,
signature: str,
identity_public_key_ed25519: str
) -> bool:
"""
Verifies signed prekey signature using Ed25519 identity key
"""
try:
spk_public_bytes = base64url_to_bytes(signed_prekey_public)
signature_bytes = base64url_to_bytes(signature)
identity_public_bytes = base64url_to_bytes(
identity_public_key_ed25519
)
identity_public = Ed25519PublicKey.from_public_bytes(
identity_public_bytes
)
identity_public.verify(signature_bytes, spk_public_bytes)
logger.debug("Signed prekey signature verified successfully")
return True
except InvalidSignature:
logger.warning("Signed prekey signature verification failed")
return False
except Exception as e:
logger.error("Error verifying signed prekey: %s", e)
return False
def perform_x3dh_sender(
self,
alice_identity_private_x25519: str,
bob_bundle: PreKeyBundle,
bob_identity_public_ed25519: str
) -> X3DHResult:
"""
Performs X3DH key exchange from sender side
"""
if not self.verify_signed_prekey(bob_bundle.signed_prekey,
bob_bundle.signed_prekey_signature,
bob_identity_public_ed25519):
raise ValueError("Invalid signed prekey signature")
alice_ik_private_bytes = base64url_to_bytes(alice_identity_private_x25519)
alice_ik_private = X25519PrivateKey.from_private_bytes(
alice_ik_private_bytes
)
alice_ek_private = X25519PrivateKey.generate()
alice_ek_public = alice_ek_private.public_key()
alice_ek_public_bytes = alice_ek_public.public_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PublicFormat.Raw
)
bob_ik_public_bytes = base64url_to_bytes(bob_bundle.identity_key)
bob_ik_public = X25519PublicKey.from_public_bytes(bob_ik_public_bytes)
bob_spk_public_bytes = base64url_to_bytes(bob_bundle.signed_prekey)
bob_spk_public = X25519PublicKey.from_public_bytes(bob_spk_public_bytes)
dh1 = alice_ik_private.exchange(bob_spk_public)
dh2 = alice_ek_private.exchange(bob_ik_public)
dh3 = alice_ek_private.exchange(bob_spk_public)
used_one_time_prekey = False
if bob_bundle.one_time_prekey:
bob_opk_public_bytes = base64url_to_bytes(bob_bundle.one_time_prekey)
bob_opk_public = X25519PublicKey.from_public_bytes(
bob_opk_public_bytes
)
dh4 = alice_ek_private.exchange(bob_opk_public)
key_material = dh1 + dh2 + dh3 + dh4
used_one_time_prekey = True
else:
key_material = dh1 + dh2 + dh3
f = b'\xff' * X25519_KEY_SIZE
hkdf = HKDF(
algorithm = hashes.SHA256(),
length = X25519_KEY_SIZE,
salt = b'\x00' * X25519_KEY_SIZE,
info = b'X3DH',
)
shared_key = hkdf.derive(f + key_material)
alice_ik_public = alice_ik_private.public_key()
alice_ik_public_bytes = alice_ik_public.public_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PublicFormat.Raw
)
associated_data = alice_ik_public_bytes + bob_ik_public_bytes
logger.info("X3DH sender completed: OPK used=%s", used_one_time_prekey)
return X3DHResult(
shared_key = shared_key,
associated_data = associated_data,
ephemeral_public_key = bytes_to_base64url(alice_ek_public_bytes),
used_one_time_prekey = used_one_time_prekey
)
def perform_x3dh_receiver(
self,
bob_identity_private_x25519: str,
bob_signed_prekey_private: str,
bob_one_time_prekey_private: str | None,
alice_identity_public_x25519: str,
alice_ephemeral_public: str
) -> X3DHResult:
"""
Performs X3DH key exchange from receiver side
"""
bob_ik_private_bytes = base64url_to_bytes(bob_identity_private_x25519)
bob_ik_private = X25519PrivateKey.from_private_bytes(bob_ik_private_bytes)
bob_spk_private_bytes = base64url_to_bytes(bob_signed_prekey_private)
bob_spk_private = X25519PrivateKey.from_private_bytes(
bob_spk_private_bytes
)
alice_ik_public_bytes = base64url_to_bytes(alice_identity_public_x25519)
alice_ik_public = X25519PublicKey.from_public_bytes(alice_ik_public_bytes)
alice_ek_public_bytes = base64url_to_bytes(alice_ephemeral_public)
alice_ek_public = X25519PublicKey.from_public_bytes(alice_ek_public_bytes)
dh1 = bob_spk_private.exchange(alice_ik_public)
dh2 = bob_ik_private.exchange(alice_ek_public)
dh3 = bob_spk_private.exchange(alice_ek_public)
used_one_time_prekey = False
if bob_one_time_prekey_private:
bob_opk_private_bytes = base64url_to_bytes(
bob_one_time_prekey_private
)
bob_opk_private = X25519PrivateKey.from_private_bytes(
bob_opk_private_bytes
)
dh4 = bob_opk_private.exchange(alice_ek_public)
key_material = dh1 + dh2 + dh3 + dh4
used_one_time_prekey = True
else:
key_material = dh1 + dh2 + dh3
f = b'\xff' * X25519_KEY_SIZE
hkdf = HKDF(
algorithm = hashes.SHA256(),
length = X25519_KEY_SIZE,
salt = b'\x00' * X25519_KEY_SIZE,
info = b'X3DH',
)
shared_key = hkdf.derive(f + key_material)
bob_ik_public = bob_ik_private.public_key()
bob_ik_public_bytes = bob_ik_public.public_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PublicFormat.Raw
)
associated_data = alice_ik_public_bytes + bob_ik_public_bytes
logger.info("X3DH receiver completed: OPK used=%s", used_one_time_prekey)
return X3DHResult(
shared_key = shared_key,
associated_data = associated_data,
ephemeral_public_key = alice_ephemeral_public,
used_one_time_prekey = used_one_time_prekey
)
x3dh_manager = X3DHManager()

View File

@ -1,12 +1,13 @@
"""
AngelaMos | 2025
Global exception handlers for FastAPI application
©AngelaMos | 2026
exception_handlers.py
"""
import logging
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from slowapi.errors import RateLimitExceeded
from app.core.exceptions import (
AuthenticationError,
@ -14,11 +15,7 @@ from app.core.exceptions import (
CredentialNotFoundError,
CredentialVerificationError,
DatabaseError,
DecryptionError,
EncryptionError,
InvalidDataError,
KeyExchangeError,
RatchetStateNotFoundError,
UserExistsError,
UserInactiveError,
UserNotFoundError,
@ -66,7 +63,7 @@ async def user_inactive_handler(
logger.warning(
"Inactive user access attempt on %s: %s",
request.url,
exc.message
exc.message,
)
return JSONResponse(
status_code = status.HTTP_403_FORBIDDEN,
@ -98,7 +95,7 @@ async def credential_verification_handler(
logger.error(
"Credential verification failed on %s: %s",
request.url,
exc.message
exc.message,
)
return JSONResponse(
status_code = status.HTTP_401_UNAUTHORIZED,
@ -162,59 +159,21 @@ async def invalid_data_handler(
)
async def encryption_error_handler(
async def rate_limit_exceeded_handler(
request: Request,
exc: EncryptionError
exc: RateLimitExceeded,
) -> JSONResponse:
"""
Handle EncryptionError exceptions
Handle slowapi RateLimitExceeded with a 429 response
"""
logger.error("Encryption error on %s: %s", request.url, exc.message)
return JSONResponse(
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR,
content = {"detail": "Encryption failed"},
logger.warning(
"Rate limit exceeded on %s: %s",
request.url,
exc.detail,
)
async def decryption_error_handler(
request: Request,
exc: DecryptionError
) -> JSONResponse:
"""
Handle DecryptionError exceptions
"""
logger.error("Decryption error on %s: %s", request.url, exc.message)
return JSONResponse(
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR,
content = {"detail": "Decryption failed"},
)
async def ratchet_state_not_found_handler(
request: Request,
exc: RatchetStateNotFoundError
) -> JSONResponse:
"""
Handle RatchetStateNotFoundError exceptions
"""
logger.warning("Ratchet state not found on %s: %s", request.url, exc.message)
return JSONResponse(
status_code = status.HTTP_404_NOT_FOUND,
content = {"detail": exc.message},
)
async def key_exchange_error_handler(
request: Request,
exc: KeyExchangeError
) -> JSONResponse:
"""
Handle KeyExchangeError exceptions
"""
logger.error("Key exchange error on %s: %s", request.url, exc.message)
return JSONResponse(
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR,
content = {"detail": "Key exchange failed"},
status_code = status.HTTP_429_TOO_MANY_REQUESTS,
content = {"detail": f"Rate limit exceeded: {exc.detail}"},
)
@ -227,20 +186,13 @@ def register_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(UserInactiveError, user_inactive_handler)
app.add_exception_handler(
CredentialNotFoundError,
credential_not_found_handler
credential_not_found_handler,
)
app.add_exception_handler(
CredentialVerificationError,
credential_verification_handler
credential_verification_handler,
)
app.add_exception_handler(ChallengeExpiredError, challenge_expired_handler)
app.add_exception_handler(DatabaseError, database_error_handler)
app.add_exception_handler(AuthenticationError, authentication_error_handler)
app.add_exception_handler(InvalidDataError, invalid_data_handler)
app.add_exception_handler(EncryptionError, encryption_error_handler)
app.add_exception_handler(DecryptionError, decryption_error_handler)
app.add_exception_handler(
RatchetStateNotFoundError,
ratchet_state_not_found_handler
)
app.add_exception_handler(KeyExchangeError, key_exchange_error_handler)

View File

@ -1,6 +1,6 @@
"""
AngelaMos | 2025
Custom application exceptions for clean error handling
©AngelaMos | 2026
exceptions.py
"""
@ -68,27 +68,3 @@ class InvalidDataError(AppException):
"""
Raised when input data is invalid
"""
class EncryptionError(AppException):
"""
Raised when message encryption fails
"""
class DecryptionError(AppException):
"""
Raised when message decryption fails
"""
class RatchetStateNotFoundError(AppException):
"""
Raised when ratchet state not found for conversation
"""
class KeyExchangeError(AppException):
"""
Raised when X3DH key exchange fails
"""

View File

@ -1,6 +1,6 @@
"""
AngelaMos | 2025
WebAuthn passkey manager using py_webauthn library
©AngelaMos | 2026
passkey_manager.py
"""
import logging
@ -81,7 +81,7 @@ class PasskeyManager:
attestation = AttestationConveyancePreference.NONE,
authenticator_selection = AuthenticatorSelectionCriteria(
resident_key = ResidentKeyRequirement.REQUIRED,
user_verification = UserVerificationRequirement.PREFERRED,
user_verification = UserVerificationRequirement.REQUIRED,
),
exclude_credentials = exclude_creds,
)
@ -125,7 +125,9 @@ class PasskeyManager:
attestation_format = verified_registration.fmt,
credential_device_type = verified_registration.credential_device_type,
credential_backed_up = verified_registration.credential_backed_up,
backup_eligible = verified_registration.credential_backed_up,
backup_eligible = (
verified_registration.credential_backup_eligible
),
backup_state = verified_registration.credential_backed_up,
)
@ -149,7 +151,7 @@ class PasskeyManager:
rp_id = self.rp_id,
challenge = challenge,
allow_credentials = allow_creds,
user_verification = UserVerificationRequirement.PREFERRED,
user_verification = UserVerificationRequirement.REQUIRED,
)
logger.debug("Generated authentication options")

View File

@ -1,14 +1,17 @@
"""
AngelaMos | 2025
Redis manager for WebAuthn challenge storage with TTL
©AngelaMos | 2026
redis_manager.py
"""
import json
import logging
import redis.asyncio as redis
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
from app.config import (
settings,
SESSION_TTL_SECONDS,
WEBAUTHN_CHALLENGE_TTL_SECONDS,
)
@ -16,9 +19,14 @@ from app.config import (
logger = logging.getLogger(__name__)
REG_CONTEXT_PREFIX = "webauthn:reg_ctx:"
AUTH_CHALLENGE_PREFIX = "webauthn:auth_challenge:"
SESSION_PREFIX = "session:"
class RedisManager:
"""
Redis manager for challenge storage with automatic expiration
Redis manager for WebAuthn challenges and session tokens
"""
def __init__(self) -> None:
"""
@ -54,121 +62,129 @@ class RedisManager:
await self.pool.aclose()
logger.info("Disconnected from Redis")
async def set_registration_challenge(
def _require_client(self) -> redis.Redis:
"""
Return the active client or raise if not connected
"""
if not self.client:
raise RuntimeError("Redis client not connected")
return self.client
async def set_registration_context(
self,
user_id: str,
username: str,
challenge: bytes,
user_handle: bytes,
display_name: str,
ttl: int = WEBAUTHN_CHALLENGE_TTL_SECONDS,
) -> None:
"""
Store registration challenge with TTL
Store registration challenge plus user handle and display name
"""
if not self.client:
raise RuntimeError("Redis client not connected")
key = f"webauthn:reg_challenge:{user_id}"
await self.client.setex(key, ttl, challenge.hex())
logger.debug(
"Stored registration challenge for user %s with %ss TTL",
user_id,
ttl
client = self._require_client()
key = f"{REG_CONTEXT_PREFIX}{username}"
payload = json.dumps(
{
"challenge": bytes_to_base64url(challenge),
"user_handle": bytes_to_base64url(user_handle),
"display_name": display_name,
}
)
await client.setex(key, ttl, payload)
logger.debug("Stored registration context for %s", username)
async def get_registration_challenge(self, user_id: str) -> bytes | None:
async def take_registration_context(
self,
username: str,
) -> dict[str, bytes | str] | None:
"""
Retrieve and delete registration challenge (one-time use)
Retrieve and delete the registration context atomically
"""
if not self.client:
raise RuntimeError("Redis client not connected")
client = self._require_client()
key = f"{REG_CONTEXT_PREFIX}{username}"
key = f"webauthn:reg_challenge:{user_id}"
async with self.client.pipeline() as pipe:
async with client.pipeline() as pipe:
await pipe.get(key)
await pipe.delete(key)
results = await pipe.execute()
challenge_hex = results[0]
if challenge_hex is None:
raw = results[0]
if raw is None:
return None
return bytes.fromhex(challenge_hex.decode())
data = json.loads(raw.decode())
return {
"challenge": base64url_to_bytes(data["challenge"]),
"user_handle": base64url_to_bytes(data["user_handle"]),
"display_name": data["display_name"],
}
async def set_authentication_challenge(
self,
user_id: str,
challenge: bytes,
ttl: int = WEBAUTHN_CHALLENGE_TTL_SECONDS,
) -> None:
"""
Store authentication challenge with TTL
Store an authentication challenge keyed by the challenge bytes
"""
if not self.client:
raise RuntimeError("Redis client not connected")
key = f"webauthn:auth_challenge:{user_id}"
await self.client.setex(key, ttl, challenge.hex())
client = self._require_client()
key = f"{AUTH_CHALLENGE_PREFIX}{bytes_to_base64url(challenge)}"
await client.set(key, b"1", ex = ttl, nx = True)
logger.debug(
"Stored authentication challenge for user %s with %ss TTL",
user_id,
ttl
"Stored authentication challenge with %ss TTL",
ttl,
)
async def get_authentication_challenge(self, user_id: str) -> bytes | None:
"""
Retrieve and delete authentication challenge (one-time use)
"""
if not self.client:
raise RuntimeError("Redis client not connected")
key = f"webauthn:auth_challenge:{user_id}"
async with self.client.pipeline() as pipe:
await pipe.get(key)
await pipe.delete(key)
results = await pipe.execute()
challenge_hex = results[0]
if challenge_hex is None:
return None
return bytes.fromhex(challenge_hex.decode())
async def set_value(
async def take_authentication_challenge(
self,
key: str,
value: str,
ttl: int | None = None
challenge: bytes,
) -> bool:
"""
Atomically verify and consume an authentication challenge
"""
client = self._require_client()
key = f"{AUTH_CHALLENGE_PREFIX}{bytes_to_base64url(challenge)}"
result = await client.delete(key)
return result == 1
async def create_session(
self,
token: str,
user_id: str,
ttl: int = SESSION_TTL_SECONDS,
) -> None:
"""
Generic set with optional TTL
Persist a session token bound to a user id with expiry
"""
if not self.client:
raise RuntimeError("Redis client not connected")
client = self._require_client()
key = f"{SESSION_PREFIX}{token}"
await client.setex(key, ttl, user_id)
logger.debug("Created session for user %s", user_id)
if ttl:
await self.client.setex(key, ttl, value)
else:
await self.client.set(key, value)
async def get_value(self, key: str) -> str | None:
async def get_session_user(
self,
token: str,
) -> str | None:
"""
Generic get
Look up the user id for a session token if it is still valid
"""
if not self.client:
raise RuntimeError("Redis client not connected")
client = self._require_client()
key = f"{SESSION_PREFIX}{token}"
raw = await client.get(key)
if raw is None:
return None
return raw.decode()
value = await self.client.get(key)
return value.decode() if value else None
async def delete_value(self, key: str) -> None:
async def delete_session(
self,
token: str,
) -> None:
"""
Generic delete
Invalidate a session token
"""
if not self.client:
raise RuntimeError("Redis client not connected")
await self.client.delete(key)
client = self._require_client()
key = f"{SESSION_PREFIX}{token}"
await client.delete(key)
redis_manager = RedisManager()

View File

@ -4,6 +4,7 @@ SurrealDB manager with live queries for real time chat features
"""
import asyncio
import contextlib
import logging
from typing import Any
from collections.abc import Callable
@ -266,6 +267,59 @@ class SurrealDBManager:
result = await self.db.query(query, {"room_id": room_id})
return self._extract_query_result(result)
async def is_room_participant(
self,
room_id: str,
user_id: str,
) -> bool:
"""
Check whether a user belongs to a given room
"""
await self.ensure_connected()
query = """
SELECT * FROM room_participants
WHERE (room_id = $room_id OR room_id = type::string($room_id))
AND user_id = $user_id
LIMIT 1
"""
result = await self.db.query(
query,
{"room_id": room_id, "user_id": user_id},
)
rows = self._extract_query_result(result)
return len(rows) > 0
async def get_room(self, room_id: str) -> dict[str, Any] | None:
"""
Look up a room by record id
"""
await self.ensure_connected()
try:
row = await self.db.select(room_id)
except Exception:
return None
if not row:
return None
if isinstance(row, list):
return row[0] if row else None
return row
async def delete_room(self, room_id: str) -> None:
"""
Delete a room and its messages and participant rows
"""
await self.ensure_connected()
await self.db.query(
"DELETE messages WHERE room_id = $room_id",
{"room_id": room_id},
)
await self.db.query(
"DELETE room_participants WHERE room_id = $room_id",
{"room_id": room_id},
)
with contextlib.suppress(Exception):
await self.db.delete(room_id)
async def get_user_rooms(self, user_id: str) -> list[RoomResponse]:
"""
Get all rooms a user is part of using graph traversal

View File

@ -159,19 +159,27 @@ class ConnectionManager:
Any]
) -> None:
"""
Broadcast message to all users in a room
Broadcast a message to all members of a room currently connected
"""
online_users = await presence_service.get_room_online_users(room_id)
participants = await surreal_db.get_room_participants(room_id)
for user_presence in online_users:
for row in participants:
raw_uid = row.get("user_id")
if not raw_uid:
continue
try:
user_id = UUID(str(raw_uid))
except ValueError:
continue
if not self.is_user_connected(user_id):
continue
try:
user_id = UUID(user_presence["user_id"])
await self.send_message(user_id, message)
except Exception as e:
logger.error(
"Failed to broadcast to user %s: %s",
user_presence['user_id'],
e
raw_uid,
e,
)
async def _heartbeat_loop(self, websocket: WebSocket, user_id: UUID) -> None:

View File

@ -1,6 +1,6 @@
"""
AngelaMos | 2025
FastAPI application factory
©AngelaMos | 2026
factory.py
"""
import logging
@ -11,26 +11,31 @@ from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from slowapi.errors import RateLimitExceeded
from app.api.auth import limiter as auth_limiter
from app.api.auth import router as auth_router
from app.api.encryption import router as encryption_router
from app.api.rooms import router as rooms_router
from app.api.websocket import router as websocket_router
from app.config import (
APP_DESCRIPTION,
APP_STATUS,
APP_VERSION,
GZIP_MINIMUM_SIZE,
settings,
)
from app.core.exception_handlers import (
rate_limit_exceeded_handler,
register_exception_handlers,
)
from app.core.redis_manager import redis_manager
from app.core.surreal_manager import surreal_db
from app.models.Base import init_db
from app.schemas.common import (
HealthResponse,
RootResponse,
)
from app.config import (
settings,
APP_VERSION,
APP_STATUS,
APP_DESCRIPTION,
GZIP_MINIMUM_SIZE,
)
from app.models.Base import init_db
from app.api.auth import router as auth_router
from app.api.rooms import router as rooms_router
from app.core.surreal_manager import surreal_db
from app.core.redis_manager import redis_manager
from app.api.encryption import router as encryption_router
from app.api.websocket import router as websocket_router
from app.core.exception_handlers import register_exception_handlers
logger = logging.getLogger(__name__)
@ -75,6 +80,9 @@ def create_app() -> FastAPI:
lifespan = lifespan,
)
app.state.limiter = auth_limiter
app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler)
app.add_middleware(
CORSMiddleware,
allow_origins = settings.CORS_ORIGINS,

View File

@ -1,9 +1,8 @@
"""
AngelaMos | 2025
X3DH identity key model for long term user identification
©AngelaMos | 2026
IdentityKey.py
"""
from typing import TYPE_CHECKING
from uuid import UUID
from sqlmodel import Field
@ -11,13 +10,10 @@ from sqlmodel import Field
from app.config import IDENTITY_KEY_LENGTH
from app.models.Base import BaseDBModel
if TYPE_CHECKING:
pass
class IdentityKey(BaseDBModel, table = True):
"""
Long term X25519 identity key for X3DH protocol
Long term X25519 identity public key for X3DH protocol
"""
__tablename__ = "identity_keys"
@ -30,16 +26,10 @@ class IdentityKey(BaseDBModel, table = True):
)
public_key: str = Field(nullable = False, max_length = IDENTITY_KEY_LENGTH)
private_key: str = Field(nullable = False, max_length = IDENTITY_KEY_LENGTH)
public_key_ed25519: str = Field(
nullable = False,
max_length = IDENTITY_KEY_LENGTH
)
private_key_ed25519: str = Field(
nullable = False,
max_length = IDENTITY_KEY_LENGTH
)
def __repr__(self) -> str:
"""

View File

@ -1,9 +1,8 @@
"""
AngelaMos | 2025
X3DH one time prekey model for single use key exchange
©AngelaMos | 2026
OneTimePrekey.py
"""
from typing import TYPE_CHECKING
from uuid import UUID
from sqlmodel import Field
@ -11,13 +10,10 @@ from sqlmodel import Field
from app.config import ONE_TIME_PREKEY_LENGTH
from app.models.Base import BaseDBModel
if TYPE_CHECKING:
pass
class OneTimePrekey(BaseDBModel, table = True):
"""
X25519 one time prekey consumed after single use for X3DH protocol
X25519 one time prekey public half consumed after single use
"""
__tablename__ = "one_time_prekeys"
@ -30,8 +26,7 @@ class OneTimePrekey(BaseDBModel, table = True):
key_id: int = Field(nullable = False, index = True)
public_key: str = Field(nullable = False, max_length = ONE_TIME_PREKEY_LENGTH)
private_key: str = Field(
public_key: str = Field(
nullable = False,
max_length = ONE_TIME_PREKEY_LENGTH
)

View File

@ -1,68 +0,0 @@
"""
AngelaMos | 2025
Double Ratchet state model for per conversation encryption state
"""
from typing import TYPE_CHECKING
from uuid import UUID
from sqlmodel import Field
from app.config import RATCHET_STATE_MAX_LENGTH
from app.models.Base import BaseDBModel
if TYPE_CHECKING:
pass
class RatchetState(BaseDBModel, table = True):
"""
Double Ratchet algorithm state for a conversation between two users
"""
__tablename__ = "ratchet_states"
id: int = Field(default = None, primary_key = True)
user_id: UUID = Field(
foreign_key = "users.id",
nullable = False,
index = True
)
peer_user_id: UUID = Field(
foreign_key = "users.id",
nullable = False,
index = True
)
dh_private_key: str | None = Field(
default = None,
max_length = RATCHET_STATE_MAX_LENGTH
)
dh_public_key: str | None = Field(
default = None,
max_length = RATCHET_STATE_MAX_LENGTH
)
dh_peer_public_key: str | None = Field(
default = None,
max_length = RATCHET_STATE_MAX_LENGTH
)
root_key: str = Field(nullable = False, max_length = RATCHET_STATE_MAX_LENGTH)
sending_chain_key: str = Field(
nullable = False,
max_length = RATCHET_STATE_MAX_LENGTH
)
receiving_chain_key: str = Field(
nullable = False,
max_length = RATCHET_STATE_MAX_LENGTH
)
sending_message_number: int = Field(default = 0, nullable = False)
receiving_message_number: int = Field(default = 0, nullable = False)
previous_sending_chain_length: int = Field(default = 0, nullable = False)
def __repr__(self) -> str:
"""
String representation of RatchetState
"""
return f"<RatchetState user_id={self.user_id} peer={self.peer_user_id}>"

View File

@ -1,10 +1,9 @@
"""
AngelaMos | 2025
X3DH signed prekey model for medium term key rotation
©AngelaMos | 2026
SignedPrekey.py
"""
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import DateTime
@ -13,13 +12,10 @@ from sqlmodel import Field
from app.config import SIGNATURE_LENGTH, SIGNED_PREKEY_LENGTH
from app.models.Base import BaseDBModel
if TYPE_CHECKING:
pass
class SignedPrekey(BaseDBModel, table = True):
"""
X25519 signed prekey rotated every 48 hours for X3DH protocol
X25519 signed prekey public half rotated periodically by the client
"""
__tablename__ = "signed_prekeys"
@ -33,8 +29,6 @@ class SignedPrekey(BaseDBModel, table = True):
key_id: int = Field(nullable = False, index = True)
public_key: str = Field(nullable = False, max_length = SIGNED_PREKEY_LENGTH)
private_key: str = Field(nullable = False, max_length = SIGNED_PREKEY_LENGTH)
signature: str = Field(nullable = False, max_length = SIGNATURE_LENGTH)
is_active: bool = Field(default = True, nullable = False)

View File

@ -1,51 +0,0 @@
"""
AngelaMos | 2025
Skipped message key storage for out of order Double Ratchet messages
"""
from typing import TYPE_CHECKING
from sqlmodel import Field
from app.models.Base import BaseDBModel
from app.config import RATCHET_STATE_MAX_LENGTH
if TYPE_CHECKING:
pass
class SkippedMessageKey(BaseDBModel, table = True):
"""
Stores message keys for out of order messages in Double Ratchet
"""
__tablename__ = "skipped_message_keys"
id: int = Field(default = None, primary_key = True)
ratchet_state_id: int = Field(
foreign_key = "ratchet_states.id",
nullable = False,
index = True
)
dh_public_key: str = Field(
nullable = False,
max_length = RATCHET_STATE_MAX_LENGTH,
index = True
)
message_number: int = Field(nullable = False, index = True)
message_key: str = Field(
nullable = False,
max_length = RATCHET_STATE_MAX_LENGTH
)
def __repr__(self) -> str:
"""
String representation of SkippedMessageKey
"""
return (
f"<SkippedMessageKey "
f"ratchet_id={self.ratchet_state_id} "
f"msg_num={self.message_number}>"
)

View File

@ -1,6 +1,6 @@
"""
AngelaMos | 2025
User model for authentication stored in PostgreSQL
©AngelaMos | 2026
User.py
"""
from typing import TYPE_CHECKING
@ -12,7 +12,6 @@ from sqlmodel import (
)
from app.config import (
DISPLAY_NAME_MAX_LENGTH,
PREKEY_MAX_LENGTH,
USERNAME_MAX_LENGTH,
)
from app.models.Base import BaseDBModel
@ -45,21 +44,12 @@ class User(BaseDBModel, table = True):
is_active: bool = Field(default = True, nullable = False)
is_verified: bool = Field(default = False, nullable = False)
credentials: list["Credential"] = Relationship(back_populates = "user")
webauthn_user_handle: bytes = Field(
nullable = False,
max_length = 64,
)
identity_key: str | None = Field(
default = None,
max_length = PREKEY_MAX_LENGTH
)
signed_prekey: str | None = Field(
default = None,
max_length = PREKEY_MAX_LENGTH
)
signed_prekey_signature: str | None = Field(
default = None,
max_length = PREKEY_MAX_LENGTH
)
one_time_prekeys: str | None = Field(default = None)
credentials: list["Credential"] = Relationship(back_populates = "user")
def __repr__(self) -> str:
"""

View File

@ -1,6 +1,6 @@
"""
AngelaMos | 2025
Database models exports
©AngelaMos | 2026
__init__.py
"""
from app.models.Base import (
@ -12,9 +12,7 @@ from app.models.Base import (
from app.models.Credential import Credential
from app.models.IdentityKey import IdentityKey
from app.models.OneTimePrekey import OneTimePrekey
from app.models.RatchetState import RatchetState
from app.models.SignedPrekey import SignedPrekey
from app.models.SkippedMessageKey import SkippedMessageKey
from app.models.User import User
@ -23,9 +21,7 @@ __all__ = [
"Credential",
"IdentityKey",
"OneTimePrekey",
"RatchetState",
"SignedPrekey",
"SkippedMessageKey",
"User",
"engine",
"get_session",

View File

@ -1,9 +1,10 @@
"""
AngelaMos | 2025
Authentication service for user and credential management
©AngelaMos | 2026
auth_service.py
"""
import logging
import secrets
from typing import Any
from uuid import UUID
from datetime import UTC, datetime
@ -28,15 +29,12 @@ from app.core.exceptions import (
from app.core.passkey.passkey_manager import passkey_manager
from app.core.redis_manager import redis_manager
from app.models.Credential import Credential
from app.models.IdentityKey import IdentityKey
from app.models.User import User
from app.services.prekey_service import prekey_service
from app.schemas.auth import (
AuthenticationBeginRequest,
AuthenticationCompleteRequest,
RegistrationBeginRequest,
RegistrationCompleteRequest,
UserResponse,
VerifiedRegistration,
)
@ -53,6 +51,7 @@ class AuthService:
session: AsyncSession,
username: str,
display_name: str,
webauthn_user_handle: bytes,
) -> User:
"""
Create a new user with username uniqueness check
@ -68,6 +67,7 @@ class AuthService:
user = User(
username = username,
display_name = display_name,
webauthn_user_handle = webauthn_user_handle,
)
session.add(user)
@ -332,8 +332,7 @@ class AuthService:
self,
session: AsyncSession,
request: RegistrationBeginRequest,
) -> dict[str,
Any]:
) -> dict[str, Any]:
"""
Begin WebAuthn passkey registration flow
"""
@ -349,25 +348,21 @@ class AuthService:
)
raise UserExistsError(f"Username {request.username} already exists")
user_id_bytes = request.username.encode()
exclude_credentials = []
if existing_user:
exclude_credentials = [
base64url_to_bytes(cred.credential_id)
for cred in existing_user.credentials
]
from app.config import WEBAUTHN_USER_HANDLE_BYTES
user_handle = secrets.token_bytes(WEBAUTHN_USER_HANDLE_BYTES)
options_response = passkey_manager.generate_registration_options(
user_id = user_id_bytes,
user_id = user_handle,
username = request.username,
display_name = request.display_name,
exclude_credentials = exclude_credentials,
exclude_credentials = [],
)
await redis_manager.set_registration_challenge(
user_id = request.username,
await redis_manager.set_registration_context(
username = request.username,
challenge = options_response.challenge,
user_handle = user_handle,
display_name = request.display_name,
)
logger.info("Started registration for user: %s", request.username)
@ -378,17 +373,17 @@ class AuthService:
session: AsyncSession,
request: RegistrationCompleteRequest,
username: str,
) -> UserResponse:
) -> User:
"""
Complete WebAuthn passkey registration
"""
expected_challenge = await redis_manager.get_registration_challenge(
user_id = username
context = await redis_manager.take_registration_context(
username = username
)
if not expected_challenge:
if context is None:
logger.warning(
"Registration challenge not found or expired for user: %s",
"Registration context not found or expired for user: %s",
username
)
raise ChallengeExpiredError(
@ -398,7 +393,7 @@ class AuthService:
try:
verified = passkey_manager.verify_registration(
credential = request.credential,
expected_challenge = expected_challenge,
expected_challenge = context["challenge"],
)
except Exception as e:
logger.error("Registration verification failed: %s", e)
@ -409,8 +404,8 @@ class AuthService:
user = await self.create_user(
session = session,
username = username,
display_name = request.credential.get("displayName",
username),
display_name = context["display_name"],
webauthn_user_handle = context["user_handle"],
)
await self.store_credential(
@ -420,28 +415,14 @@ class AuthService:
device_name = request.device_name,
)
await prekey_service.initialize_user_keys(
session = session,
user_id = user.id,
)
logger.info("Registration completed for user: %s", username)
return UserResponse(
id = str(user.id),
username = user.username,
display_name = user.display_name,
is_active = user.is_active,
is_verified = user.is_verified,
created_at = user.created_at.isoformat(),
)
return user
async def begin_authentication(
self,
session: AsyncSession,
request: AuthenticationBeginRequest,
) -> dict[str,
Any]:
) -> dict[str, Any]:
"""
Begin WebAuthn passkey authentication flow
"""
@ -453,43 +434,31 @@ class AuthService:
username = request.username,
)
if not user:
logger.warning(
"Authentication attempt for non-existent user: %s",
request.username
)
raise UserNotFoundError("User not found")
if not user.is_active:
logger.warning(
"Authentication attempt for inactive user: %s",
request.username
)
raise UserInactiveError("User account is inactive")
allow_credentials = [
base64url_to_bytes(cred.credential_id)
for cred in user.credentials
]
if user is not None and user.is_active:
allow_credentials = [
base64url_to_bytes(cred.credential_id)
for cred in user.credentials
]
options_response = passkey_manager.generate_authentication_options(
allow_credentials = allow_credentials,
)
user_id = request.username if request.username else "discoverable"
await redis_manager.set_authentication_challenge(
user_id = user_id,
challenge = options_response.challenge,
)
logger.info("Started authentication for user: %s", user_id)
logger.info(
"Started authentication (username hint: %s)",
request.username or "<discoverable>"
)
return options_response.options
async def complete_authentication(
self,
session: AsyncSession,
request: AuthenticationCompleteRequest,
) -> UserResponse:
) -> User:
"""
Complete WebAuthn passkey authentication
"""
@ -497,6 +466,33 @@ class AuthService:
if not credential_id:
raise InvalidDataError("Missing credential ID")
client_data_b64 = request.credential.get("response", {}).get(
"clientDataJSON"
)
if not client_data_b64:
raise InvalidDataError("Missing clientDataJSON")
try:
import json as _json
client_data = _json.loads(
base64url_to_bytes(client_data_b64).decode()
)
challenge_bytes = base64url_to_bytes(client_data["challenge"])
except Exception as exc:
logger.error("Failed to parse clientDataJSON: %s", exc)
raise InvalidDataError("Malformed clientDataJSON") from exc
challenge_consumed = await redis_manager.take_authentication_challenge(
challenge = challenge_bytes
)
if not challenge_consumed:
logger.warning(
"Authentication challenge invalid or expired"
)
raise ChallengeExpiredError(
"Challenge expired or not found - please restart authentication"
)
credential = await self.get_credential_by_id(
session = session,
credential_id = credential_id,
@ -528,23 +524,10 @@ class AuthService:
)
raise UserInactiveError("User account is inactive")
expected_challenge = await redis_manager.get_authentication_challenge(
user_id = user.username
)
if not expected_challenge:
logger.warning(
"Authentication challenge not found for user: %s",
user.username
)
raise ChallengeExpiredError(
"Challenge expired or not found - please restart authentication"
)
try:
verified = passkey_manager.verify_authentication(
credential = request.credential,
expected_challenge = expected_challenge,
expected_challenge = challenge_bytes,
credential_public_key = base64url_to_bytes(credential.public_key),
credential_current_sign_count = credential.sign_count,
)
@ -572,30 +555,8 @@ class AuthService:
backup_eligible = verified.backup_eligible,
)
ik_statement = select(IdentityKey).where(IdentityKey.user_id == user.id)
ik_result = await session.execute(ik_statement)
existing_ik = ik_result.scalar_one_or_none()
if not existing_ik:
logger.info(
"Initializing encryption keys for existing user: %s",
user.username
)
await prekey_service.initialize_user_keys(
session = session,
user_id = user.id,
)
logger.info("Authentication successful for user: %s", user.username)
return UserResponse(
id = str(user.id),
username = user.username,
display_name = user.display_name,
is_active = user.is_active,
is_verified = user.is_verified,
created_at = user.created_at.isoformat(),
)
return user
auth_service = AuthService()

View File

@ -1,41 +1,22 @@
"""
AngelaMos | 2025
Message service with end-to-end encryption using Double Ratchet
©AngelaMos | 2026
message_service.py
"""
import json
import logging
from typing import Any
from uuid import UUID
from datetime import UTC, datetime
from sqlmodel import select
from sqlalchemy.exc import IntegrityError
from sqlmodel.ext.asyncio.session import AsyncSession
from cryptography.hazmat.primitives import serialization
from webauthn.helpers import base64url_to_bytes, bytes_to_base64url
from cryptography.hazmat.primitives.asymmetric.x25519 import (
X25519PrivateKey,
)
from app.core.encryption.double_ratchet import (
DoubleRatchetState,
EncryptedMessage,
double_ratchet,
)
from app.core.encryption.x3dh_manager import x3dh_manager
from app.core.exceptions import (
DatabaseError,
DecryptionError,
EncryptionError,
InvalidDataError,
KeyExchangeError,
RatchetStateNotFoundError,
UserNotFoundError,
)
from app.core.surreal_manager import surreal_db
from app.models.IdentityKey import IdentityKey
from app.models.RatchetState import RatchetState
from app.models.User import User
from app.services.prekey_service import prekey_service
logger = logging.getLogger(__name__)
@ -43,229 +24,8 @@ logger = logging.getLogger(__name__)
class MessageService:
"""
Service for encrypted messaging using Double Ratchet protocol
Pass-through message storage service for client-encrypted messages
"""
async def initialize_conversation(
self,
session: AsyncSession,
sender_id: UUID,
recipient_id: UUID
) -> RatchetState:
"""
Performs X3DH key exchange and initializes Double Ratchet for new conversation
"""
if sender_id == recipient_id:
raise InvalidDataError("Cannot start conversation with yourself")
existing_state_statement = select(RatchetState).where(
RatchetState.user_id == sender_id,
RatchetState.peer_user_id == recipient_id
)
existing_state_result = await session.execute(existing_state_statement)
existing_state = existing_state_result.scalar_one_or_none()
if existing_state:
logger.warning(
"Ratchet state already exists for %s -> %s",
sender_id,
recipient_id
)
return existing_state
sender_ik_statement = select(IdentityKey).where(
IdentityKey.user_id == sender_id
)
sender_ik_result = await session.execute(sender_ik_statement)
sender_ik = sender_ik_result.scalar_one_or_none()
if not sender_ik:
logger.error("Sender identity key not found: %s", sender_id)
raise InvalidDataError(
"Sender has no identity key - initialize encryption first"
)
recipient_bundle = await prekey_service.get_prekey_bundle(
session,
recipient_id
)
recipient_ik_statement = select(IdentityKey).where(
IdentityKey.user_id == recipient_id
)
recipient_ik_result = await session.execute(recipient_ik_statement)
recipient_ik = recipient_ik_result.scalar_one_or_none()
if not recipient_ik:
logger.error("Recipient identity key not found: %s", recipient_id)
raise InvalidDataError("Recipient has no identity key")
try:
x3dh_result = x3dh_manager.perform_x3dh_sender(
alice_identity_private_x25519 = sender_ik.private_key,
bob_bundle = recipient_bundle,
bob_identity_public_ed25519 = recipient_ik.public_key_ed25519
)
except Exception as e:
logger.error("X3DH key exchange failed: %s", e)
raise KeyExchangeError(f"Key exchange failed: {str(e)}") from e
recipient_spk_public_bytes = base64url_to_bytes(
recipient_bundle.signed_prekey
)
dr_state = double_ratchet.initialize_sender(
shared_key = x3dh_result.shared_key,
peer_public_key = recipient_spk_public_bytes
)
dh_private_bytes = dr_state.dh_private_key.private_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PrivateFormat.Raw,
encryption_algorithm = serialization.NoEncryption()
) if dr_state.dh_private_key else b''
dh_public_bytes = dr_state.dh_private_key.public_key().public_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PublicFormat.Raw
) if dr_state.dh_private_key else b''
ratchet_state = RatchetState(
user_id = sender_id,
peer_user_id = recipient_id,
dh_private_key = bytes_to_base64url(dh_private_bytes),
dh_public_key = bytes_to_base64url(dh_public_bytes),
dh_peer_public_key = bytes_to_base64url(dr_state.dh_peer_public_key)
if dr_state.dh_peer_public_key else None,
root_key = bytes_to_base64url(dr_state.root_key),
sending_chain_key = bytes_to_base64url(dr_state.sending_chain_key),
receiving_chain_key = bytes_to_base64url(
dr_state.receiving_chain_key
),
sending_message_number = dr_state.sending_message_number,
receiving_message_number = dr_state.receiving_message_number,
previous_sending_chain_length = (
dr_state.previous_sending_chain_length
)
)
session.add(ratchet_state)
try:
await session.commit()
await session.refresh(ratchet_state)
logger.info(
"Initialized conversation: %s -> %s (X3DH complete)",
sender_id,
recipient_id
)
except IntegrityError as e:
await session.rollback()
logger.error("Database error saving ratchet state: %s", e)
raise DatabaseError("Failed to initialize conversation") from e
return ratchet_state
async def _load_ratchet_state_from_db(
self,
ratchet_state_db: RatchetState
) -> DoubleRatchetState:
"""
Converts database RatchetState to DoubleRatchetState object
"""
dh_private_key = None
if ratchet_state_db.dh_private_key:
dh_private_bytes = base64url_to_bytes(ratchet_state_db.dh_private_key)
dh_private_key = X25519PrivateKey.from_private_bytes(dh_private_bytes)
dh_peer_public_key = None
if ratchet_state_db.dh_peer_public_key:
dh_peer_public_key = base64url_to_bytes(
ratchet_state_db.dh_peer_public_key
)
root_key = base64url_to_bytes(ratchet_state_db.root_key)
sending_chain_key = base64url_to_bytes(ratchet_state_db.sending_chain_key)
receiving_chain_key = base64url_to_bytes(
ratchet_state_db.receiving_chain_key
)
return DoubleRatchetState(
root_key = root_key,
sending_chain_key = sending_chain_key,
receiving_chain_key = receiving_chain_key,
dh_private_key = dh_private_key,
dh_peer_public_key = dh_peer_public_key,
sending_message_number = ratchet_state_db.sending_message_number,
receiving_message_number = (
ratchet_state_db.receiving_message_number
),
previous_sending_chain_length = (
ratchet_state_db.previous_sending_chain_length
),
skipped_message_keys = {}
)
async def _save_ratchet_state_to_db(
self,
session: AsyncSession,
ratchet_state_db: RatchetState,
dr_state: DoubleRatchetState
) -> None:
"""
Updates database RatchetState from DoubleRatchetState object
"""
if dr_state.dh_private_key:
dh_private_bytes = dr_state.dh_private_key.private_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PrivateFormat.Raw,
encryption_algorithm = serialization.NoEncryption()
)
dh_public_bytes = dr_state.dh_private_key.public_key().public_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PublicFormat.Raw
)
ratchet_state_db.dh_private_key = bytes_to_base64url(dh_private_bytes)
ratchet_state_db.dh_public_key = bytes_to_base64url(dh_public_bytes)
else:
ratchet_state_db.dh_private_key = None
ratchet_state_db.dh_public_key = None
if dr_state.dh_peer_public_key:
ratchet_state_db.dh_peer_public_key = bytes_to_base64url(
dr_state.dh_peer_public_key
)
else:
ratchet_state_db.dh_peer_public_key = None
ratchet_state_db.root_key = bytes_to_base64url(dr_state.root_key)
ratchet_state_db.sending_chain_key = bytes_to_base64url(
dr_state.sending_chain_key
)
ratchet_state_db.receiving_chain_key = bytes_to_base64url(
dr_state.receiving_chain_key
)
ratchet_state_db.sending_message_number = (
dr_state.sending_message_number
)
ratchet_state_db.receiving_message_number = (
dr_state.receiving_message_number
)
ratchet_state_db.previous_sending_chain_length = (
dr_state.previous_sending_chain_length
)
try:
await session.commit()
logger.debug(
"Saved ratchet state: send=%s, recv=%s",
dr_state.sending_message_number,
dr_state.receiving_message_number
)
except IntegrityError as e:
await session.rollback()
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,
@ -277,7 +37,7 @@ class MessageService:
room_id: str | None = None,
) -> Any:
"""
Stores client-encrypted message in SurrealDB (pass-through, no server encryption)
Stores client-encrypted message in SurrealDB without decrypting it
"""
sender_user_statement = select(User).where(User.id == sender_id)
sender_user_result = await session.execute(sender_user_statement)
@ -286,8 +46,6 @@ class MessageService:
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),
@ -313,157 +71,5 @@ class MessageService:
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,
sender_id: UUID,
recipient_id: UUID,
plaintext: str,
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(
RatchetState.user_id == sender_id,
RatchetState.peer_user_id == recipient_id
)
ratchet_state_result = await session.execute(ratchet_state_statement)
ratchet_state_db = ratchet_state_result.scalar_one_or_none()
if not ratchet_state_db:
logger.warning(
"No ratchet state for %s -> %s, initializing",
sender_id,
recipient_id
)
ratchet_state_db = await self.initialize_conversation(
session,
sender_id,
recipient_id
)
dr_state = await self._load_ratchet_state_from_db(ratchet_state_db)
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")
associated_data = f"{sender_id}:{recipient_id}".encode()
try:
encrypted_msg = double_ratchet.encrypt_message(
dr_state,
plaintext.encode(),
associated_data
)
except Exception as e:
logger.error("Encryption failed: %s", e)
raise EncryptionError(f"Failed to encrypt message: {str(e)}") from e
await self._save_ratchet_state_to_db(session, ratchet_state_db, dr_state)
message_header = {
"dh_public_key": bytes_to_base64url(encrypted_msg.dh_public_key),
"message_number": encrypted_msg.message_number,
"previous_chain_length": encrypted_msg.previous_chain_length
}
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": bytes_to_base64url(encrypted_msg.ciphertext),
"nonce": bytes_to_base64url(encrypted_msg.nonce),
"header": json.dumps(message_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(
"Sent encrypted message: %s -> %s (msg #%s)",
sender_id,
recipient_id,
encrypted_msg.message_number
)
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 decrypt_received_message(
self,
session: AsyncSession,
recipient_id: UUID,
message_data: dict[str,
Any]
) -> str:
"""
Decrypts received message using Double Ratchet
"""
sender_id = UUID(message_data["sender_id"])
ratchet_state_statement = select(RatchetState).where(
RatchetState.user_id == recipient_id,
RatchetState.peer_user_id == sender_id
)
ratchet_state_result = await session.execute(ratchet_state_statement)
ratchet_state_db = ratchet_state_result.scalar_one_or_none()
if not ratchet_state_db:
logger.error(
"No ratchet state for receiving: %s <- %s",
recipient_id,
sender_id
)
raise RatchetStateNotFoundError("No encryption session with sender")
dr_state = await self._load_ratchet_state_from_db(ratchet_state_db)
header = json.loads(message_data["header"])
encrypted_msg = EncryptedMessage(
ciphertext = base64url_to_bytes(message_data["ciphertext"]),
nonce = base64url_to_bytes(message_data["nonce"]),
dh_public_key = base64url_to_bytes(header["dh_public_key"]),
message_number = header["message_number"],
previous_chain_length = header["previous_chain_length"]
)
associated_data = f"{sender_id}:{recipient_id}".encode()
try:
plaintext_bytes = double_ratchet.decrypt_message(
dr_state,
encrypted_msg,
associated_data
)
except Exception as e:
logger.error("Decryption failed: %s", e)
raise DecryptionError(f"Failed to decrypt message: {str(e)}") from e
await self._save_ratchet_state_to_db(session, ratchet_state_db, dr_state)
plaintext = plaintext_bytes.decode()
logger.info(
"Decrypted message: %s <- %s (msg #%s)",
recipient_id,
sender_id,
encrypted_msg.message_number
)
return plaintext
message_service = MessageService()

View File

@ -1,6 +1,6 @@
"""
AngelaMos | 2025
Prekey management service for X3DH key bundles
©AngelaMos | 2026
prekey_service.py
"""
import logging
@ -9,6 +9,7 @@ from datetime import (
datetime,
timedelta,
)
from dataclasses import dataclass
from uuid import UUID
from sqlmodel import select
@ -16,14 +17,8 @@ from sqlalchemy.exc import IntegrityError
from sqlmodel.ext.asyncio.session import AsyncSession
from app.config import (
DEFAULT_ONE_TIME_PREKEY_COUNT,
SIGNED_PREKEY_RETENTION_DAYS,
SIGNED_PREKEY_ROTATION_HOURS,
)
from app.core.encryption.x3dh_manager import (
PreKeyBundle,
x3dh_manager,
)
from app.core.exceptions import (
DatabaseError,
InvalidDataError,
@ -38,9 +33,21 @@ from app.models.OneTimePrekey import OneTimePrekey
logger = logging.getLogger(__name__)
@dataclass
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
class PrekeyService:
"""
Service for managing X3DH prekey bundles and key rotation
Manages publication and retrieval of client-generated public prekey bundles
"""
async def store_client_keys(
self,
@ -53,8 +60,7 @@ class PrekeyService:
one_time_prekeys: list[str]
) -> IdentityKey:
"""
Stores client-generated public keys for E2E encryption.
Only stores PUBLIC keys - private keys remain on client.
Stores client generated public keys for E2E encryption
"""
statement = select(User).where(User.id == user_id)
result = await session.execute(statement)
@ -73,14 +79,12 @@ class PrekeyService:
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)
logger.info("Updated 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 = ""
public_key_ed25519 = identity_key_ed25519
)
session.add(existing_ik)
logger.info("Created identity key for user %s", user_id)
@ -110,7 +114,6 @@ class PrekeyService:
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
@ -122,14 +125,15 @@ class PrekeyService:
).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
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)
@ -149,154 +153,13 @@ class PrekeyService:
return existing_ik
async def initialize_user_keys(
self,
session: AsyncSession,
user_id: UUID
) -> IdentityKey:
"""
Generates and stores initial identity key,
signed prekey, and one time prekeys for a user
"""
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:
logger.warning("Identity key already exists for user %s", user_id)
return existing_ik
ik_private_x25519, ik_public_x25519 = (
x3dh_manager.generate_identity_keypair_x25519()
)
ik_private_ed25519, ik_public_ed25519 = (
x3dh_manager.generate_identity_keypair_ed25519()
)
identity_key = IdentityKey(
user_id = user_id,
public_key = ik_public_x25519,
private_key = ik_private_x25519,
public_key_ed25519 = ik_public_ed25519,
private_key_ed25519 = ik_private_ed25519
)
session.add(identity_key)
try:
await session.commit()
await session.refresh(identity_key)
logger.info("Created identity key for user %s", user_id)
except IntegrityError as e:
await session.rollback()
logger.error("Database error creating identity key: %s", e)
raise DatabaseError("Failed to create identity key") from e
await self.rotate_signed_prekey(session, user_id)
await self.replenish_one_time_prekeys(
session,
user_id,
DEFAULT_ONE_TIME_PREKEY_COUNT
)
logger.info(
"Initialized all keys for user %s: IK + SPK + %s OPKs",
user_id,
DEFAULT_ONE_TIME_PREKEY_COUNT
)
return identity_key
async def rotate_signed_prekey(
self,
session: AsyncSession,
user_id: UUID
) -> SignedPrekey:
"""
Generates new signed prekey and marks old ones inactive
"""
ik_statement = select(IdentityKey).where(IdentityKey.user_id == user_id)
ik_result = await session.execute(ik_statement)
identity_key = ik_result.scalar_one_or_none()
if not identity_key:
logger.error("Identity key not found for user %s", user_id)
raise InvalidDataError("User has no identity key")
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
logger.debug("Marked SPK %s as inactive", old_spk.key_id)
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_key_id = (max_key_id + 1) if max_key_id is not None else 1
spk_private, spk_public, spk_signature = (
x3dh_manager.generate_signed_prekey(
identity_key.private_key_ed25519
)
)
expires_at = datetime.now(UTC) + timedelta(
hours = SIGNED_PREKEY_ROTATION_HOURS
)
signed_prekey = SignedPrekey(
user_id = user_id,
key_id = new_key_id,
public_key = spk_public,
private_key = spk_private,
signature = spk_signature,
is_active = True,
expires_at = expires_at
)
session.add(signed_prekey)
try:
await session.commit()
await session.refresh(signed_prekey)
logger.info(
"Rotated signed prekey for user %s: key_id=%s, expires=%s",
user_id,
new_key_id,
expires_at
)
except IntegrityError as e:
await session.rollback()
logger.error("Database error rotating signed prekey: %s", e)
raise DatabaseError("Failed to rotate signed prekey") from e
return signed_prekey
async def get_prekey_bundle(
self,
session: AsyncSession,
user_id: UUID
) -> PreKeyBundle:
"""
Retrieves prekey bundle for initiating X3DH with a user
Retrieves prekey bundle for initiating X3DH key exchange
"""
ik_statement = select(IdentityKey).where(IdentityKey.user_id == user_id)
ik_result = await session.execute(ik_statement)
@ -304,7 +167,7 @@ class PrekeyService:
if not identity_key:
logger.error("Identity key not found for user %s", user_id)
raise InvalidDataError("User has no identity key")
raise InvalidDataError("User has not uploaded keys yet")
spk_statement = select(SignedPrekey).where(
SignedPrekey.user_id == user_id,
@ -314,15 +177,11 @@ class PrekeyService:
signed_prekey = spk_result.scalar_one_or_none()
if not signed_prekey:
logger.warning(
"No active signed prekey for user %s, rotating",
user_id
)
signed_prekey = await self.rotate_signed_prekey(session, user_id)
raise InvalidDataError("User has no active signed prekey")
opk_statement = select(OneTimePrekey).where(
OneTimePrekey.user_id == user_id,
not OneTimePrekey.is_used
OneTimePrekey.is_used.is_(False)
).limit(1)
opk_result = await session.execute(opk_statement)
one_time_prekey = opk_result.scalar_one_or_none()
@ -353,116 +212,12 @@ class PrekeyService:
)
logger.info(
"Retrieved prekey bundle for user %s: IK + SPK + %s",
"Retrieved prekey bundle for user %s (OPK: %s)",
user_id,
'OPK' if one_time_prekey_public else 'no OPK'
'yes' if one_time_prekey_public else 'no'
)
return bundle
async def replenish_one_time_prekeys(
self,
session: AsyncSession,
user_id: UUID,
count: int = DEFAULT_ONE_TIME_PREKEY_COUNT
) -> int:
"""
Generates new batch of one time prekeys
"""
max_key_id_statement = select(OneTimePrekey.key_id).where(
OneTimePrekey.user_id == user_id
).order_by(OneTimePrekey.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()
next_key_id = (max_key_id + 1) if max_key_id is not None else 1
one_time_prekeys = []
for i in range(count):
opk_private, opk_public = x3dh_manager.generate_one_time_prekey()
one_time_prekey = OneTimePrekey(
user_id = user_id,
key_id = next_key_id + i,
public_key = opk_public,
private_key = opk_private,
is_used = False
)
one_time_prekeys.append(one_time_prekey)
for opk in one_time_prekeys:
session.add(opk)
try:
await session.commit()
logger.info(
"Generated %s one-time prekeys for user %s",
count,
user_id
)
except IntegrityError as e:
await session.rollback()
logger.error("Database error generating OPKs: %s", e)
raise DatabaseError("Failed to generate one-time prekeys") from e
return count
async def get_unused_opk_count(
self,
session: AsyncSession,
user_id: UUID
) -> int:
"""
Returns count of unused one time prekeys for a user
"""
count_statement = select(OneTimePrekey).where(
OneTimePrekey.user_id == user_id,
not OneTimePrekey.is_used
)
result = await session.execute(count_statement)
unused_opks = result.scalars().all()
count = len(unused_opks)
logger.debug("User %s has %s unused OPKs", user_id, count)
return count
async def cleanup_old_signed_prekeys(
self,
session: AsyncSession,
user_id: UUID
) -> int:
"""
Deletes inactive signed prekeys older than retention period
"""
cutoff_date = datetime.now(UTC) - timedelta(
days = SIGNED_PREKEY_RETENTION_DAYS
)
old_spks_statement = select(SignedPrekey).where(
SignedPrekey.user_id == user_id,
not SignedPrekey.is_active,
SignedPrekey.created_at < cutoff_date
)
old_spks_result = await session.execute(old_spks_statement)
old_spks = old_spks_result.scalars().all()
deleted_count = len(old_spks)
for spk in old_spks:
await session.delete(spk)
try:
await session.commit()
logger.info(
"Deleted %s old signed prekeys for user %s",
deleted_count,
user_id
)
except IntegrityError as e:
await session.rollback()
logger.error("Database error deleting old SPKs: %s", e)
raise DatabaseError("Failed to delete old signed prekeys") from e
return deleted_count
prekey_service = PrekeyService()

View File

@ -1,9 +1,11 @@
"""
AngelaMos | 2025
WebSocket service for handling real time message routing and processing
©AngelaMos | 2026
websocket_service.py
"""
import logging
import time
from collections import defaultdict, deque
from typing import Any
from uuid import UUID
from datetime import UTC, datetime
@ -11,12 +13,14 @@ from datetime import UTC, datetime
from fastapi import WebSocket
from app.config import (
RATE_LIMIT_WS_MESSAGE,
WS_MESSAGE_TYPE_ENCRYPTED,
WS_MESSAGE_TYPE_PRESENCE,
WS_MESSAGE_TYPE_RECEIPT,
WS_MESSAGE_TYPE_TYPING,
)
from app.core.enums import PresenceStatus
from app.core.surreal_manager import surreal_db
from app.core.websocket_manager import connection_manager
from app.schemas.websocket import (
EncryptedMessageWS,
@ -32,6 +36,23 @@ from app.services.presence_service import presence_service
logger = logging.getLogger(__name__)
_message_timestamps: dict[UUID, deque[float]] = defaultdict(deque)
def _check_message_rate(user_id: UUID) -> bool:
"""
Per-user sliding window of one minute capped by RATE_LIMIT_WS_MESSAGE
"""
now = time.monotonic()
window = _message_timestamps[user_id]
while window and now - window[0] > 60.0:
window.popleft()
if len(window) >= RATE_LIMIT_WS_MESSAGE:
return False
window.append(now)
return True
class WebSocketService:
"""
Service for processing WebSocket
@ -93,6 +114,10 @@ class WebSocketService:
"""
Process client-encrypted message and forward to recipient (pass-through)
"""
if not _check_message_rate(user_id):
logger.warning("WS message rate limit hit for %s", user_id)
return
try:
recipient_id = UUID(message.get("recipient_id"))
room_id = message.get("room_id")
@ -109,6 +134,21 @@ class WebSocketService:
logger.error("Missing room_id in message from %s", user_id)
return
sender_member = await surreal_db.is_room_participant(
room_id, str(user_id)
)
recipient_member = await surreal_db.is_room_participant(
room_id, str(recipient_id)
)
if not sender_member or not recipient_member:
logger.warning(
"Membership check failed: sender=%s recipient=%s room=%s",
user_id,
recipient_id,
room_id,
)
return
async with async_session_maker() as session:
result = await message_service.store_encrypted_message(
session,
@ -198,6 +238,17 @@ class WebSocketService:
)
return
sender_member = await surreal_db.is_room_participant(
room_id, str(user_id)
)
if not sender_member:
logger.warning(
"Typing indicator from non-member %s for room %s",
user_id,
room_id,
)
return
typing_msg = TypingIndicatorWS(
user_id = str(user_id),
room_id = room_id,

View File

@ -27,7 +27,8 @@ dependencies = [
"httpx>=0.28.1",
"orjson>=3.11.4",
"surrealdb>=1.0.6",
"liboqs-python>=0.14.1"
"liboqs-python>=0.14.1",
"slowapi>=0.1.9",
]
[project.optional-dependencies]
dev = [

View File

@ -1,11 +1,10 @@
"""
AngelaMos | 2025
Pytest configuration and fixtures for all tests
©AngelaMos | 2026
conftest.py
"""
import asyncio
from uuid import uuid4
from typing import Any
import secrets
from collections.abc import AsyncGenerator
import pytest
@ -16,13 +15,8 @@ from sqlalchemy.ext.asyncio import (
create_async_engine,
)
from sqlalchemy.orm import sessionmaker
from webauthn.helpers import bytes_to_base64url
from app.models.User import User
from app.models.IdentityKey import IdentityKey
from app.models.SignedPrekey import SignedPrekey
from app.models.OneTimePrekey import OneTimePrekey
from app.core.encryption.x3dh_manager import x3dh_manager
@pytest.fixture(scope = "session")
@ -72,6 +66,7 @@ async def test_user(db_session: AsyncSession) -> User:
display_name = "Test User",
is_active = True,
is_verified = True,
webauthn_user_handle = secrets.token_bytes(64),
)
db_session.add(user)
await db_session.commit()
@ -89,121 +84,9 @@ async def test_user_2(db_session: AsyncSession) -> User:
display_name = "Test User 2",
is_active = True,
is_verified = True,
webauthn_user_handle = secrets.token_bytes(64),
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest_asyncio.fixture
async def test_identity_key(
db_session: AsyncSession,
test_user: User
) -> IdentityKey:
"""
Create identity key for test user
"""
ik_private_x25519, ik_public_x25519 = (
x3dh_manager.generate_identity_keypair_x25519()
)
ik_private_ed25519, ik_public_ed25519 = (
x3dh_manager.generate_identity_keypair_ed25519()
)
identity_key = IdentityKey(
user_id = test_user.id,
public_key = ik_public_x25519,
private_key = ik_private_x25519,
public_key_ed25519 = ik_public_ed25519,
private_key_ed25519 = ik_private_ed25519,
)
db_session.add(identity_key)
await db_session.commit()
await db_session.refresh(identity_key)
return identity_key
@pytest_asyncio.fixture
async def test_signed_prekey(
db_session: AsyncSession,
test_user: User,
test_identity_key: IdentityKey
) -> SignedPrekey:
"""
Create signed prekey for test user
"""
spk_private, spk_public, spk_signature = x3dh_manager.generate_signed_prekey(
test_identity_key.private_key_ed25519
)
signed_prekey = SignedPrekey(
user_id = test_user.id,
key_id = 1,
public_key = spk_public,
private_key = spk_private,
signature = spk_signature,
is_active = True,
)
db_session.add(signed_prekey)
await db_session.commit()
await db_session.refresh(signed_prekey)
return signed_prekey
@pytest_asyncio.fixture
async def test_one_time_prekey(
db_session: AsyncSession,
test_user: User
) -> OneTimePrekey:
"""
Create one-time prekey for test user
"""
opk_private, opk_public = x3dh_manager.generate_one_time_prekey()
one_time_prekey = OneTimePrekey(
user_id = test_user.id,
key_id = 1,
public_key = opk_public,
private_key = opk_private,
is_used = False,
)
db_session.add(one_time_prekey)
await db_session.commit()
await db_session.refresh(one_time_prekey)
return one_time_prekey
@pytest.fixture
def mock_webauthn_credential() -> dict[str, Any]:
"""
Mock WebAuthn credential response
"""
return {
"id": bytes_to_base64url(uuid4().bytes),
"rawId": bytes_to_base64url(uuid4().bytes),
"type": "public-key",
"response": {
"clientDataJSON": bytes_to_base64url(b'{"type":"webauthn.create"}'),
"attestationObject": bytes_to_base64url(b"mock_attestation"),
},
}
@pytest.fixture
def sample_plaintext() -> str:
"""
Sample message for encryption tests
"""
return "Hello, this is a test message!"
@pytest.fixture
def sample_associated_data() -> bytes:
"""
Sample associated data for AEAD
"""
return b"test_sender:test_recipient"

View File

@ -1,8 +1,10 @@
"""
AngelaMos | 2025
Tests for authentication service
©AngelaMos | 2026
test_auth_service.py
"""
import secrets
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
@ -23,7 +25,8 @@ class TestAuthService:
user = await auth_service.create_user(
session = db_session,
username = "newuser",
display_name = "New User"
display_name = "New User",
webauthn_user_handle = secrets.token_bytes(64),
)
assert user.id is not None
@ -31,12 +34,13 @@ class TestAuthService:
assert user.display_name == "New User"
assert user.is_active is True
assert user.is_verified is False
assert len(user.webauthn_user_handle) == 64
@pytest.mark.asyncio
async def test_create_duplicate_user_fails(
self,
db_session: AsyncSession,
test_user: User
test_user: User,
):
"""
Test cannot create user with duplicate username
@ -45,21 +49,22 @@ class TestAuthService:
await auth_service.create_user(
session = db_session,
username = test_user.username,
display_name = "Duplicate"
display_name = "Duplicate",
webauthn_user_handle = secrets.token_bytes(64),
)
@pytest.mark.asyncio
async def test_get_user_by_username(
self,
db_session: AsyncSession,
test_user: User
test_user: User,
):
"""
Test retrieving user by username
"""
user = await auth_service.get_user_by_username(
session = db_session,
username = test_user.username
username = test_user.username,
)
assert user is not None
@ -73,7 +78,7 @@ class TestAuthService:
"""
user = await auth_service.get_user_by_username(
session = db_session,
username = "nonexistent"
username = "nonexistent",
)
assert user is None
@ -82,14 +87,14 @@ class TestAuthService:
async def test_get_user_by_id(
self,
db_session: AsyncSession,
test_user: User
test_user: User,
):
"""
Test retrieving user by ID
"""
user = await auth_service.get_user_by_id(
session = db_session,
user_id = test_user.id
user_id = test_user.id,
)
assert user is not None

View File

@ -1,165 +0,0 @@
"""
AngelaMos | 2025
Tests for Double Ratchet encryption core
"""
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from app.core.encryption.double_ratchet import double_ratchet
class TestDoubleRatchet:
"""
Test Double Ratchet encryption/decryption
"""
def test_encrypt_decrypt_basic(
self,
sample_plaintext: str,
sample_associated_data: bytes
):
"""
Test basic encrypt/decrypt cycle works
"""
shared_key = b"0" * 32
bob_dh_private = X25519PrivateKey.generate()
bob_dh_public_bytes = bob_dh_private.public_key().public_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PublicFormat.Raw
)
sender_state = double_ratchet.initialize_sender(
shared_key = shared_key,
peer_public_key = bob_dh_public_bytes
)
receiver_state = double_ratchet.initialize_receiver(
shared_key = shared_key,
own_private_key = bob_dh_private
)
encrypted = double_ratchet.encrypt_message(
sender_state,
sample_plaintext.encode(),
sample_associated_data
)
decrypted = double_ratchet.decrypt_message(
receiver_state,
encrypted,
sample_associated_data
)
assert decrypted.decode() == sample_plaintext
def test_multiple_messages(self, sample_associated_data: bytes):
"""
Test multiple messages maintain state correctly
"""
shared_key = b"0" * 32
bob_dh_private = X25519PrivateKey.generate()
bob_dh_public_bytes = bob_dh_private.public_key().public_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PublicFormat.Raw
)
sender_state = double_ratchet.initialize_sender(
shared_key = shared_key,
peer_public_key = bob_dh_public_bytes
)
receiver_state = double_ratchet.initialize_receiver(
shared_key = shared_key,
own_private_key = bob_dh_private
)
messages = [b"Message 1", b"Message 2", b"Message 3"]
encrypted_messages = []
for msg in messages:
encrypted = double_ratchet.encrypt_message(
sender_state,
msg,
sample_associated_data
)
encrypted_messages.append(encrypted)
for i, encrypted in enumerate(encrypted_messages):
decrypted = double_ratchet.decrypt_message(
receiver_state,
encrypted,
sample_associated_data
)
assert decrypted == messages[i]
def test_message_numbers_increment(self, sample_associated_data: bytes):
"""
Test message numbers increment correctly
"""
shared_key = b"0" * 32
peer_public_key = b"1" * 32
sender_state = double_ratchet.initialize_sender(
shared_key = shared_key,
peer_public_key = peer_public_key
)
assert sender_state.sending_message_number == 0
double_ratchet.encrypt_message(
sender_state,
b"Message 1",
sample_associated_data
)
assert sender_state.sending_message_number == 1
double_ratchet.encrypt_message(
sender_state,
b"Message 2",
sample_associated_data
)
assert sender_state.sending_message_number == 2
def test_tampered_message_fails(self, sample_associated_data: bytes):
"""
Test tampered messages fail to decrypt
"""
shared_key = b"0" * 32
bob_dh_private = X25519PrivateKey.generate()
bob_dh_public_bytes = bob_dh_private.public_key().public_bytes(
encoding = serialization.Encoding.Raw,
format = serialization.PublicFormat.Raw
)
sender_state = double_ratchet.initialize_sender(
shared_key = shared_key,
peer_public_key = bob_dh_public_bytes
)
receiver_state = double_ratchet.initialize_receiver(
shared_key = shared_key,
own_private_key = bob_dh_private
)
encrypted = double_ratchet.encrypt_message(
sender_state,
b"Original message",
sample_associated_data
)
tampered_ciphertext = bytearray(encrypted.ciphertext)
tampered_ciphertext[0] ^= 0xFF
encrypted.ciphertext = bytes(tampered_ciphertext)
with pytest.raises(ValueError, match = "tampered or corrupted"):
double_ratchet.decrypt_message(
receiver_state,
encrypted,
sample_associated_data
)

View File

@ -1,135 +0,0 @@
"""
AngelaMos | 2025
Tests for message service (end to end encryption flow)
"""
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.User import User
from app.models.IdentityKey import IdentityKey
from app.models.SignedPrekey import SignedPrekey
from app.core.exceptions import InvalidDataError
from app.models.OneTimePrekey import OneTimePrekey
from app.services.message_service import message_service
from app.core.encryption.x3dh_manager import x3dh_manager
class TestMessageService:
"""
Test message encryption/decryption service
"""
@pytest.mark.asyncio
async def test_initialize_conversation(
self,
db_session: AsyncSession,
test_user: User,
test_user_2: User,
test_identity_key: IdentityKey,
test_signed_prekey: SignedPrekey,
test_one_time_prekey: OneTimePrekey
):
"""
Test initializing encrypted conversation between two users
"""
sender_ik_private, sender_ik_public = (
x3dh_manager.generate_identity_keypair_x25519()
)
sender_ik_private_ed, sender_ik_public_ed = (
x3dh_manager.generate_identity_keypair_ed25519()
)
sender_identity_key = IdentityKey(
user_id = test_user_2.id,
public_key = sender_ik_public,
private_key = sender_ik_private,
public_key_ed25519 = sender_ik_public_ed,
private_key_ed25519 = sender_ik_private_ed,
)
db_session.add(sender_identity_key)
await db_session.commit()
ratchet_state = await message_service.initialize_conversation(
session = db_session,
sender_id = test_user_2.id,
recipient_id = test_user.id
)
assert ratchet_state.user_id == test_user_2.id
assert ratchet_state.peer_user_id == test_user.id
assert ratchet_state.sending_message_number == 0
assert ratchet_state.receiving_message_number == 0
assert ratchet_state.dh_public_key is not None
@pytest.mark.asyncio
async def test_cannot_initialize_with_self(
self,
db_session: AsyncSession,
test_user: User
):
"""
Test cannot start conversation with yourself
"""
with pytest.raises(InvalidDataError,
match = "Cannot start conversation with yourself"):
await message_service.initialize_conversation(
session = db_session,
sender_id = test_user.id,
recipient_id = test_user.id
)
@pytest.mark.asyncio
async def test_ratchet_state_persistence(
self,
db_session: AsyncSession,
test_user: User,
test_user_2: User,
test_identity_key: IdentityKey,
test_signed_prekey: SignedPrekey,
test_one_time_prekey: OneTimePrekey
):
"""
Test ratchet state loads and saves correctly
"""
sender_ik_private, sender_ik_public = (
x3dh_manager.generate_identity_keypair_x25519()
)
sender_ik_private_ed, sender_ik_public_ed = (
x3dh_manager.generate_identity_keypair_ed25519()
)
sender_identity_key = IdentityKey(
user_id = test_user_2.id,
public_key = sender_ik_public,
private_key = sender_ik_private,
public_key_ed25519 = sender_ik_public_ed,
private_key_ed25519 = sender_ik_private_ed,
)
db_session.add(sender_identity_key)
await db_session.commit()
ratchet_state = await message_service.initialize_conversation(
session = db_session,
sender_id = test_user_2.id,
recipient_id = test_user.id
)
initial_msg_num = ratchet_state.sending_message_number
dr_state = await message_service._load_ratchet_state_from_db(
ratchet_state
)
assert dr_state.sending_message_number == initial_msg_num
assert dr_state.dh_private_key is not None
dr_state.sending_message_number += 1
await message_service._save_ratchet_state_to_db(
db_session,
ratchet_state,
dr_state
)
await db_session.refresh(ratchet_state)
assert ratchet_state.sending_message_number == initial_msg_num + 1

View File

@ -0,0 +1,155 @@
"""
©AngelaMos | 2026
test_prekey_service.py
"""
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import InvalidDataError
from app.models.IdentityKey import IdentityKey
from app.models.OneTimePrekey import OneTimePrekey
from app.models.SignedPrekey import SignedPrekey
from app.models.User import User
from app.services.prekey_service import prekey_service
def _seed_identity(user: User) -> IdentityKey:
"""
Build a fake IdentityKey row for the given user
"""
return IdentityKey(
user_id = user.id,
public_key = "AAAA",
public_key_ed25519 = "BBBB",
)
def _seed_signed_prekey(user: User) -> SignedPrekey:
"""
Build an active signed prekey row for the given user
"""
return SignedPrekey(
user_id = user.id,
key_id = 1,
public_key = "CCCC",
signature = "DDDD",
is_active = True,
)
class TestPrekeyService:
"""
Tests for prekey bundle storage and retrieval
"""
@pytest.mark.asyncio
async def test_get_prekey_bundle_returns_unused_opk(
self,
db_session: AsyncSession,
test_user: User,
) -> None:
"""
Regression for the broken `not Column` filter that hid every OPK
"""
db_session.add(_seed_identity(test_user))
db_session.add(_seed_signed_prekey(test_user))
for key_id, is_used in enumerate([True, True, False, False, False], 1):
db_session.add(
OneTimePrekey(
user_id = test_user.id,
key_id = key_id,
public_key = f"opk{key_id}",
is_used = is_used,
)
)
await db_session.commit()
bundle = await prekey_service.get_prekey_bundle(
db_session, test_user.id
)
assert bundle.one_time_prekey is not None
assert bundle.one_time_prekey.startswith("opk")
@pytest.mark.asyncio
async def test_get_prekey_bundle_no_opk_when_all_used(
self,
db_session: AsyncSession,
test_user: User,
) -> None:
"""
Bundle still returns when every OPK is consumed
"""
db_session.add(_seed_identity(test_user))
db_session.add(_seed_signed_prekey(test_user))
db_session.add(
OneTimePrekey(
user_id = test_user.id,
key_id = 1,
public_key = "opk1",
is_used = True,
)
)
await db_session.commit()
bundle = await prekey_service.get_prekey_bundle(
db_session, test_user.id
)
assert bundle.one_time_prekey is None
@pytest.mark.asyncio
async def test_get_prekey_bundle_requires_identity_key(
self,
db_session: AsyncSession,
test_user: User,
) -> None:
"""
Bundle lookup fails cleanly when keys were never uploaded
"""
with pytest.raises(InvalidDataError):
await prekey_service.get_prekey_bundle(
db_session, test_user.id
)
@pytest.mark.asyncio
async def test_store_client_keys_replaces_active_spk(
self,
db_session: AsyncSession,
test_user: User,
) -> None:
"""
Uploading new keys deactivates the previous active SPK
"""
await prekey_service.store_client_keys(
db_session,
test_user.id,
identity_key = "ik1",
identity_key_ed25519 = "ik1ed",
signed_prekey = "spk1",
signed_prekey_signature = "sig1",
one_time_prekeys = ["opk1", "opk2"],
)
await prekey_service.store_client_keys(
db_session,
test_user.id,
identity_key = "ik2",
identity_key_ed25519 = "ik2ed",
signed_prekey = "spk2",
signed_prekey_signature = "sig2",
one_time_prekeys = ["opk3"],
)
from sqlmodel import select
active_spks = (
await db_session.execute(
select(SignedPrekey).where(
SignedPrekey.user_id == test_user.id,
SignedPrekey.is_active.is_(True),
)
)
).scalars().all()
assert len(active_spks) == 1
assert active_spks[0].public_key == "spk2"

View File

@ -0,0 +1,101 @@
"""
©AngelaMos | 2026
test_session_auth.py
"""
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.api.auth import router as auth_router
from app.api.encryption import router as encryption_router
from app.api.rooms import router as rooms_router
from app.core.exception_handlers import register_exception_handlers
@pytest.fixture
def app(monkeypatch) -> FastAPI:
"""
Build a FastAPI app without DB or Redis lifespan for protected-route checks
"""
app = FastAPI()
register_exception_handlers(app)
app.include_router(auth_router)
app.include_router(rooms_router)
app.include_router(encryption_router)
return app
class _StubRedis:
"""
No-op Redis stand-in for unauthenticated tests
"""
async def get_session_user(self, token: str) -> str | None:
"""
Always return None to simulate an absent session
"""
return None
@pytest.fixture(autouse = True)
def stub_redis(monkeypatch):
"""
Replace the real Redis client used by the auth dependency
"""
from app.core import redis_manager as redis_module
monkeypatch.setattr(redis_module, "redis_manager", _StubRedis())
@pytest.mark.asyncio
async def test_me_requires_session(app: FastAPI) -> None:
"""
/auth/me returns 401 with no cookie
"""
transport = ASGITransport(app = app)
async with AsyncClient(transport = transport, base_url = "http://test") as ac:
resp = await ac.get("/auth/me")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_rooms_list_requires_session(app: FastAPI) -> None:
"""
GET /rooms returns 401 with no cookie
"""
transport = ASGITransport(app = app)
async with AsyncClient(transport = transport, base_url = "http://test") as ac:
resp = await ac.get("/rooms")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_rooms_create_requires_session(app: FastAPI) -> None:
"""
POST /rooms returns 401 with no cookie
"""
transport = ASGITransport(app = app)
async with AsyncClient(transport = transport, base_url = "http://test") as ac:
resp = await ac.post(
"/rooms",
json = {
"participant_id": "00000000-0000-0000-0000-000000000000",
"room_type": "direct",
},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_search_users_requires_session(app: FastAPI) -> None:
"""
POST /auth/users/search returns 401 with no cookie
"""
transport = ASGITransport(app = app)
async with AsyncClient(transport = transport, base_url = "http://test") as ac:
resp = await ac.post(
"/auth/users/search",
json = {"query": "alice", "limit": 10},
)
assert resp.status_code == 401

View File

@ -1,130 +0,0 @@
"""
AngelaMos | 2025
Tests for X3DH key exchange protocol
"""
from webauthn.helpers import base64url_to_bytes
from app.core.encryption.x3dh_manager import x3dh_manager, PreKeyBundle
class TestX3DH:
"""
Test X3DH key exchange
"""
def test_key_generation(self):
"""
Test all key generation functions work
"""
ik_private, ik_public = x3dh_manager.generate_identity_keypair_x25519()
assert len(base64url_to_bytes(ik_private)) == 32
assert len(base64url_to_bytes(ik_public)) == 32
ik_private_ed, ik_public_ed = x3dh_manager.generate_identity_keypair_ed25519()
assert len(base64url_to_bytes(ik_private_ed)) == 32
assert len(base64url_to_bytes(ik_public_ed)) == 32
spk_private, spk_public, signature = x3dh_manager.generate_signed_prekey(
ik_private_ed
)
assert len(base64url_to_bytes(spk_private)) == 32
assert len(base64url_to_bytes(spk_public)) == 32
assert len(base64url_to_bytes(signature)) == 64
opk_private, opk_public = x3dh_manager.generate_one_time_prekey()
assert len(base64url_to_bytes(opk_private)) == 32
assert len(base64url_to_bytes(opk_public)) == 32
def test_x3dh_handshake_with_opk(self):
"""
Test full X3DH handshake with one-time prekey
"""
alice_ik_private, alice_ik_public = (
x3dh_manager.generate_identity_keypair_x25519()
)
alice_ik_private_ed, alice_ik_public_ed = (
x3dh_manager.generate_identity_keypair_ed25519()
)
bob_ik_private, bob_ik_public = (
x3dh_manager.generate_identity_keypair_x25519()
)
bob_ik_private_ed, bob_ik_public_ed = (
x3dh_manager.generate_identity_keypair_ed25519()
)
bob_spk_private, bob_spk_public, bob_spk_sig = (
x3dh_manager.generate_signed_prekey(bob_ik_private_ed)
)
bob_opk_private, bob_opk_public = x3dh_manager.generate_one_time_prekey()
bob_bundle = PreKeyBundle(
identity_key = bob_ik_public,
signed_prekey = bob_spk_public,
signed_prekey_signature = bob_spk_sig,
one_time_prekey = bob_opk_public
)
alice_result = x3dh_manager.perform_x3dh_sender(
alice_identity_private_x25519 = alice_ik_private,
bob_bundle = bob_bundle,
bob_identity_public_ed25519 = bob_ik_public_ed
)
bob_result = x3dh_manager.perform_x3dh_receiver(
bob_identity_private_x25519 = bob_ik_private,
bob_signed_prekey_private = bob_spk_private,
bob_one_time_prekey_private = bob_opk_private,
alice_ephemeral_public = alice_result.ephemeral_public_key,
alice_identity_public_x25519 = alice_ik_public
)
assert alice_result.shared_key == bob_result.shared_key
assert len(alice_result.shared_key) == 32
def test_x3dh_handshake_without_opk(self):
"""
Test X3DH handshake without one-time prekey
"""
alice_ik_private, alice_ik_public = (
x3dh_manager.generate_identity_keypair_x25519()
)
alice_ik_private_ed, alice_ik_public_ed = (
x3dh_manager.generate_identity_keypair_ed25519()
)
bob_ik_private, bob_ik_public = (
x3dh_manager.generate_identity_keypair_x25519()
)
bob_ik_private_ed, bob_ik_public_ed = (
x3dh_manager.generate_identity_keypair_ed25519()
)
bob_spk_private, bob_spk_public, bob_spk_sig = (
x3dh_manager.generate_signed_prekey(bob_ik_private_ed)
)
bob_bundle = PreKeyBundle(
identity_key = bob_ik_public,
signed_prekey = bob_spk_public,
signed_prekey_signature = bob_spk_sig,
one_time_prekey = None
)
alice_result = x3dh_manager.perform_x3dh_sender(
alice_identity_private_x25519 = alice_ik_private,
bob_bundle = bob_bundle,
bob_identity_public_ed25519 = bob_ik_public_ed
)
bob_result = x3dh_manager.perform_x3dh_receiver(
bob_identity_private_x25519 = bob_ik_private,
bob_signed_prekey_private = bob_spk_private,
bob_one_time_prekey_private = None,
alice_ephemeral_public = alice_result.ephemeral_public_key,
alice_identity_public_x25519 = alice_ik_public
)
assert alice_result.shared_key == bob_result.shared_key
assert len(alice_result.shared_key) == 32

View File

@ -404,6 +404,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
]
[[package]]
name = "deprecated"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
]
[[package]]
name = "distlib"
version = "0.4.0"
@ -432,6 +444,7 @@ dependencies = [
{ name = "pynacl" },
{ name = "python-multipart" },
{ name = "redis", extra = ["hiredis"] },
{ name = "slowapi" },
{ name = "sqlalchemy" },
{ name = "sqlmodel" },
{ name = "surrealdb" },
@ -474,6 +487,7 @@ requires-dist = [
{ name = "python-multipart", specifier = ">=0.0.26" },
{ name = "redis", extras = ["hiredis"], specifier = ">=7.1.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" },
{ name = "slowapi", specifier = ">=0.1.9" },
{ name = "sqlalchemy", specifier = ">=2.0.44" },
{ name = "sqlmodel", specifier = ">=0.0.27" },
{ name = "surrealdb", specifier = ">=1.0.6" },
@ -804,6 +818,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/cb/940431d9410fda74f941f5cd7f0e5a22c63be7b0c10fa98b2b7022b48cb1/librt-0.7.5-cp314-cp314t-win_arm64.whl", hash = "sha256:08153ea537609d11f774d2bfe84af39d50d5c9ca3a4d061d946e0c9d8bce04a1", size = 39728, upload-time = "2025-12-25T03:53:03.306Z" },
]
[[package]]
name = "limits"
version = "5.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "packaging" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
@ -1448,6 +1476,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
]
[[package]]
name = "slowapi"
version = "0.1.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "limits" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.45"
@ -1763,6 +1803,59 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]
name = "wrapt"
version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" },
{ url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" },
{ url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" },
{ url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" },
{ url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" },
{ url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" },
{ url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" },
{ url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" },
{ url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" },
{ url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" },
{ url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" },
{ url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" },
{ url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" },
{ url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" },
{ url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" },
{ url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" },
{ url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" },
{ url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" },
{ url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" },
{ url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" },
{ url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" },
{ url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" },
{ url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" },
{ url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" },
{ url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" },
{ url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" },
{ url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" },
{ url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" },
{ url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" },
{ url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" },
{ url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" },
{ url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" },
{ url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" },
{ url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" },
{ url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" },
{ url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" },
{ url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" },
{ url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
]
[[package]]
name = "yarl"
version = "1.22.0"

View File

@ -14,7 +14,9 @@
"format": "biome format --write .",
"format:prettier": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@nanostores/persistent": "^1.3.3",
@ -40,6 +42,7 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.0",
"vite": "^7.3.2",
"vite-plugin-solid": "^2.11.10"
"vite-plugin-solid": "^2.11.10",
"vitest": "^2.1.5"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -120,7 +120,8 @@ class CryptoService {
)
await saveSignedPreKey(this.userId, this.signedPreKey)
await api.encryption.rotateSignedPrekey(this.userId)
const unusedOTPs = await getUnusedOneTimePreKeys(this.userId)
await this.uploadPublicKeys(unusedOTPs)
}
private async replenishOneTimePreKeys(): Promise<void> {
@ -130,6 +131,7 @@ class CryptoService {
DEFAULT_ONE_TIME_PREKEY_COUNT / 2
)
await saveOneTimePreKeys(this.userId, newPreKeys)
await this.uploadPublicKeys(newPreKeys)
}
private async uploadPublicKeys(oneTimePreKeys: OneTimePreKey[]): Promise<void> {

View File

@ -0,0 +1,121 @@
// ===================
// © AngelaMos | 2026
// double-ratchet.test.ts
// ===================
import { describe, expect, it } from 'vitest'
import type { PreKeyBundle } from '../types'
import {
decryptMessage,
encryptMessage,
initializeRatchetReceiver,
initializeRatchetSender,
} from './double-ratchet'
import { exportPublicKey, generateX25519KeyPair } from './primitives'
import {
generateIdentityKeyPair,
generateOneTimePreKeys,
generateSignedPreKey,
initiateX3DH,
receiveX3DH,
} from './x3dh'
async function bootstrapSession() {
const aliceIdentity = await generateIdentityKeyPair()
const bobIdentity = await generateIdentityKeyPair()
const bobSignedPreKey = await generateSignedPreKey(bobIdentity.ed25519_private)
const [bobOPK] = await generateOneTimePreKeys(1)
const bobBundle: PreKeyBundle = {
identity_key: bobIdentity.x25519_public,
identity_key_ed25519: bobIdentity.ed25519_public,
signed_prekey: bobSignedPreKey.public_key,
signed_prekey_signature: bobSignedPreKey.signature,
one_time_prekey: bobOPK.public_key,
}
const aliceX3DH = await initiateX3DH(aliceIdentity, bobBundle)
const bobShared = await receiveX3DH(
bobIdentity,
bobSignedPreKey,
bobOPK,
aliceIdentity.x25519_public,
aliceX3DH.ephemeral_public_key
)
const bobDH = await generateX25519KeyPair()
const bobDHPublicBytes = await exportPublicKey(bobDH.publicKey)
const aliceState = await initializeRatchetSender(
'bob',
aliceX3DH.shared_key,
bobDHPublicBytes
)
const bobState = await initializeRatchetReceiver('alice', bobShared, bobDH)
return { aliceState, bobState }
}
describe('Double Ratchet', () => {
it('round-trips a single message', async () => {
const { aliceState, bobState } = await bootstrapSession()
const plaintext = new TextEncoder().encode('hello bob')
const encrypted = await encryptMessage(aliceState, plaintext)
const decrypted = await decryptMessage(bobState, encrypted)
expect(new TextDecoder().decode(decrypted)).toBe('hello bob')
})
it('handles multiple messages in order', async () => {
const { aliceState, bobState } = await bootstrapSession()
const messages = ['m1', 'm2', 'm3', 'm4']
const encryptedList = []
for (const m of messages) {
encryptedList.push(
await encryptMessage(aliceState, new TextEncoder().encode(m))
)
}
const decryptedTexts: string[] = []
for (const enc of encryptedList) {
const dec = await decryptMessage(bobState, enc)
decryptedTexts.push(new TextDecoder().decode(dec))
}
expect(decryptedTexts).toEqual(messages)
expect(aliceState.sending_message_number).toBe(messages.length)
expect(bobState.receiving_message_number).toBe(messages.length)
})
it('handles out-of-order messages via skipped keys', async () => {
const { aliceState, bobState } = await bootstrapSession()
const m1 = await encryptMessage(aliceState, new TextEncoder().encode('m1'))
const m2 = await encryptMessage(aliceState, new TextEncoder().encode('m2'))
const m3 = await encryptMessage(aliceState, new TextEncoder().encode('m3'))
const dec3 = await decryptMessage(bobState, m3)
expect(new TextDecoder().decode(dec3)).toBe('m3')
const dec1 = await decryptMessage(bobState, m1)
expect(new TextDecoder().decode(dec1)).toBe('m1')
const dec2 = await decryptMessage(bobState, m2)
expect(new TextDecoder().decode(dec2)).toBe('m2')
})
it('refuses to decrypt a tampered ciphertext', async () => {
const { aliceState, bobState } = await bootstrapSession()
const encrypted = await encryptMessage(
aliceState,
new TextEncoder().encode('plaintext')
)
encrypted.ciphertext[0] ^= 0xff
await expect(decryptMessage(bobState, encrypted)).rejects.toThrow()
})
})

View File

@ -1,5 +1,5 @@
// ===================
// © AngelaMos | 2025
// © AngelaMos | 2026
// double-ratchet.ts
// ===================
import type {
@ -26,8 +26,8 @@ import {
} from './primitives'
const RATCHET_INFO = new TextEncoder().encode('DoubleRatchet')
const MESSAGE_KEY_INFO = new TextEncoder().encode('MessageKey')
const CHAIN_KEY_INFO = new TextEncoder().encode('ChainKey')
const MESSAGE_KEY_BYTE = new Uint8Array([0x01])
const CHAIN_KEY_BYTE = new Uint8Array([0x02])
function createSkippedKeyId(
dhPublicKey: Uint8Array,
@ -47,13 +47,7 @@ export async function initializeRatchetSender(
const peerKey = await importX25519PublicKey(peerPublicKey)
const dhOutput = await x25519DeriveSharedSecret(dhKeyPair.privateKey, peerKey)
const rootChainInput = concatBytes(sharedKey, dhOutput)
const derivedKeys = await hkdfDerive(
rootChainInput,
new Uint8Array(32),
RATCHET_INFO,
64
)
const derivedKeys = await hkdfDerive(dhOutput, sharedKey, RATCHET_INFO, 64)
const rootKey = derivedKeys.slice(0, 32)
const sendingChainKey = derivedKeys.slice(32, 64)
@ -99,8 +93,8 @@ async function deriveMessageKey(chainKey: Uint8Array): Promise<{
messageKey: Uint8Array
nextChainKey: Uint8Array
}> {
const messageKey = await hmacSha256(chainKey, MESSAGE_KEY_INFO)
const nextChainKey = await hmacSha256(chainKey, CHAIN_KEY_INFO)
const messageKey = await hmacSha256(chainKey, MESSAGE_KEY_BYTE)
const nextChainKey = await hmacSha256(chainKey, CHAIN_KEY_BYTE)
return {
messageKey: messageKey.slice(0, 32),
@ -127,14 +121,7 @@ async function performDHRatchet(
peerKey
)
const rootChainInput = concatBytes(state.root_key, dhOutput)
const derivedKeys = await hkdfDerive(
rootChainInput,
new Uint8Array(32),
RATCHET_INFO,
64
)
const derivedKeys = await hkdfDerive(dhOutput, state.root_key, RATCHET_INFO, 64)
state.root_key = derivedKeys.slice(0, 32)
state.receiving_chain_key = derivedKeys.slice(32, 64)
@ -146,14 +133,12 @@ async function performDHRatchet(
newDHKeyPair.privateKey,
peerKey
)
const newRootChainInput = concatBytes(state.root_key, newDHOutput)
const newDerivedKeys = await hkdfDerive(
newRootChainInput,
new Uint8Array(32),
newDHOutput,
state.root_key,
RATCHET_INFO,
64
)
state.root_key = newDerivedKeys.slice(0, 32)
state.sending_chain_key = newDerivedKeys.slice(32, 64)
}

View File

@ -1,11 +1,22 @@
// ===================
// © AngelaMos | 2025
// © AngelaMos | 2026
// index.ts
// ===================
export * from './crypto-service'
export * from './double-ratchet'
export * from './key-store'
export * from './message-store'
export {
clearAllMessages,
clearRoomMessages,
deleteMessage,
getDecryptedMessage,
getDecryptedMessages,
getLatestMessageTimestamp,
getMessageCount,
saveDecryptedMessage,
saveDecryptedMessages,
updateMessageId,
} from './message-store'
export * from './primitives'
export * from './x3dh'

View File

@ -1,7 +1,8 @@
// ===================
// © AngelaMos | 2025
// © AngelaMos | 2026
// primitives.ts
// ===================
import { base64UrlDecode, base64UrlEncode } from '../lib/base64'
import {
AES_GCM_KEY_SIZE,
AES_GCM_NONCE_SIZE,
@ -376,16 +377,11 @@ export function concatBytes(...arrays: Uint8Array[]): Uint8Array {
}
export function bytesToBase64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
return base64UrlEncode(bytes)
}
export function base64ToBytes(base64: string): Uint8Array {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
return base64UrlDecode(base64)
}
export function bytesToHex(bytes: Uint8Array): string {

View File

@ -0,0 +1,133 @@
// ===================
// © AngelaMos | 2026
// x3dh.test.ts
// ===================
import { describe, expect, it } from 'vitest'
import type { PreKeyBundle } from '../types'
import {
generateIdentityKeyPair,
generateOneTimePreKeys,
generateSignedPreKey,
initiateX3DH,
receiveX3DH,
verifySignedPreKey,
} from './x3dh'
describe('X3DH', () => {
it('produces the same shared key on both sides with one-time prekey', async () => {
const aliceIdentity = await generateIdentityKeyPair()
const bobIdentity = await generateIdentityKeyPair()
const bobSignedPreKey = await generateSignedPreKey(
bobIdentity.ed25519_private
)
const [bobOPK] = await generateOneTimePreKeys(1)
const bobBundle: PreKeyBundle = {
identity_key: bobIdentity.x25519_public,
identity_key_ed25519: bobIdentity.ed25519_public,
signed_prekey: bobSignedPreKey.public_key,
signed_prekey_signature: bobSignedPreKey.signature,
one_time_prekey: bobOPK.public_key,
}
const aliceResult = await initiateX3DH(aliceIdentity, bobBundle)
const bobSharedKey = await receiveX3DH(
bobIdentity,
bobSignedPreKey,
bobOPK,
aliceIdentity.x25519_public,
aliceResult.ephemeral_public_key
)
expect(aliceResult.shared_key).toEqual(bobSharedKey)
expect(aliceResult.shared_key.length).toBe(32)
expect(aliceResult.used_one_time_prekey).toBe(true)
})
it('produces the same shared key on both sides without OPK', async () => {
const aliceIdentity = await generateIdentityKeyPair()
const bobIdentity = await generateIdentityKeyPair()
const bobSignedPreKey = await generateSignedPreKey(
bobIdentity.ed25519_private
)
const bobBundle: PreKeyBundle = {
identity_key: bobIdentity.x25519_public,
identity_key_ed25519: bobIdentity.ed25519_public,
signed_prekey: bobSignedPreKey.public_key,
signed_prekey_signature: bobSignedPreKey.signature,
one_time_prekey: null,
}
const aliceResult = await initiateX3DH(aliceIdentity, bobBundle)
const bobSharedKey = await receiveX3DH(
bobIdentity,
bobSignedPreKey,
null,
aliceIdentity.x25519_public,
aliceResult.ephemeral_public_key
)
expect(aliceResult.shared_key).toEqual(bobSharedKey)
expect(aliceResult.used_one_time_prekey).toBe(false)
})
it('verifies a valid signed prekey signature', async () => {
const identity = await generateIdentityKeyPair()
const signedPreKey = await generateSignedPreKey(identity.ed25519_private)
const valid = await verifySignedPreKey(
identity.ed25519_public,
signedPreKey.public_key,
signedPreKey.signature
)
expect(valid).toBe(true)
})
it('rejects a tampered signed prekey signature', async () => {
const identity = await generateIdentityKeyPair()
const signedPreKey = await generateSignedPreKey(identity.ed25519_private)
const otherIdentity = await generateIdentityKeyPair()
const otherSignedPreKey = await generateSignedPreKey(
otherIdentity.ed25519_private
)
const valid = await verifySignedPreKey(
identity.ed25519_public,
signedPreKey.public_key,
otherSignedPreKey.signature
)
expect(valid).toBe(false)
})
it('rejects an X3DH bundle with a forged signature', async () => {
const aliceIdentity = await generateIdentityKeyPair()
const bobIdentity = await generateIdentityKeyPair()
const bobSignedPreKey = await generateSignedPreKey(
bobIdentity.ed25519_private
)
const malloryIdentity = await generateIdentityKeyPair()
const malloryForgedSpk = await generateSignedPreKey(
malloryIdentity.ed25519_private
)
const tamperedBundle: PreKeyBundle = {
identity_key: bobIdentity.x25519_public,
identity_key_ed25519: bobIdentity.ed25519_public,
signed_prekey: bobSignedPreKey.public_key,
signed_prekey_signature: malloryForgedSpk.signature,
one_time_prekey: null,
}
await expect(initiateX3DH(aliceIdentity, tamperedBundle)).rejects.toThrow(
/Invalid signed prekey signature/
)
})
})

View File

@ -32,6 +32,7 @@ import {
const X3DH_INFO = new TextEncoder().encode('X3DH')
const EMPTY_SALT = new Uint8Array(HKDF_OUTPUT_SIZE)
const X3DH_F_PREFIX = new Uint8Array(32).fill(0xff)
export async function generateIdentityKeyPair(): Promise<IdentityKeyPair> {
const x25519KeyPair = await generateX25519KeyPair()
@ -173,7 +174,7 @@ export async function initiateX3DH(
dhResults = [dh1, dh2, dh3]
}
const concatenated = concatBytes(...dhResults)
const concatenated = concatBytes(X3DH_F_PREFIX, ...dhResults)
const sharedKey = await hkdfDerive(concatenated, EMPTY_SALT, X3DH_INFO, 32)
const senderIdentityPublic = base64ToBytes(identityKeyPair.x25519_public)
@ -236,7 +237,7 @@ export async function receiveX3DH(
dhResults = [dh1, dh2, dh3]
}
const concatenated = concatBytes(...dhResults)
const concatenated = concatBytes(X3DH_F_PREFIX, ...dhResults)
const sharedKey = await hkdfDerive(concatenated, EMPTY_SALT, X3DH_INFO, 32)
return sharedKey

View File

@ -1,9 +1,14 @@
// ===================
// © AngelaMos | 2026
// index.tsx
// ===================
import { Router } from '@solidjs/router'
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
import { render } from 'solid-js/web'
import App from './App'
import { ToastContainer } from './components/UI/Toast'
import './index.css'
import { authService } from './services'
const queryClient = new QueryClient({
defaultOptions: {
@ -21,6 +26,8 @@ if (root === null) {
throw new Error('Root element not found')
}
void authService.rehydrateSession()
render(
() => (
<QueryClientProvider client={queryClient}>

View File

@ -0,0 +1,266 @@
// ===================
// © AngelaMos | 2026
// api-client.ts
// ===================
import { API_URL } from '../config'
import type {
ApiErrorResponse,
AuthenticationBeginRequest,
AuthenticationCompleteRequest,
PreKeyBundle,
RegistrationBeginRequest,
RegistrationCompleteRequest,
Room,
User,
} from '../types'
import { ApiError } from '../types'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
interface RequestOptions {
method?: HttpMethod
body?: unknown
headers?: Record<string, string>
signal?: AbortSignal
}
interface ApiClientConfig {
baseUrl: string
defaultHeaders: Record<string, string>
}
const DEFAULT_CONFIG: ApiClientConfig = {
baseUrl: API_URL,
defaultHeaders: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
async function request<T>(
endpoint: string,
options: RequestOptions = {}
): Promise<T> {
const { method = 'GET', body, headers = {}, signal } = options
const url = `${DEFAULT_CONFIG.baseUrl}${endpoint}`
const fetchOptions: RequestInit = {
method,
headers: {
...DEFAULT_CONFIG.defaultHeaders,
...headers,
},
signal,
credentials: 'include',
}
if (body !== undefined && method !== 'GET') {
fetchOptions.body = JSON.stringify(body)
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
let errorData: unknown
try {
errorData = await response.json()
} catch {
errorData = { detail: response.statusText }
}
const getErrorMessage = (): string => {
if (
typeof errorData === 'object' &&
errorData !== null &&
'detail' in errorData
) {
const detail = (errorData as ApiErrorResponse).detail
return typeof detail === 'string'
? detail
: `HTTP ${response.status}: ${response.statusText}`
}
return `HTTP ${response.status}: ${response.statusText}`
}
throw new ApiError(response.status, errorData, getErrorMessage())
}
if (response.status === 204) {
return undefined as T
}
return response.json() as Promise<T>
}
async function get<T>(endpoint: string, signal?: AbortSignal): Promise<T> {
return request<T>(endpoint, { method: 'GET', signal })
}
async function post<T>(
endpoint: string,
body?: unknown,
signal?: AbortSignal
): Promise<T> {
return request<T>(endpoint, { method: 'POST', body, signal })
}
async function put<T>(
endpoint: string,
body?: unknown,
signal?: AbortSignal
): Promise<T> {
return request<T>(endpoint, { method: 'PUT', body, signal })
}
async function del<T>(endpoint: string, signal?: AbortSignal): Promise<T> {
return request<T>(endpoint, { method: 'DELETE', signal })
}
export interface RootResponse {
app: string
version: string
status: string
environment: string
}
export interface HealthResponse {
status: string
}
export interface WebAuthnOptionsResponse
extends PublicKeyCredentialCreationOptions,
PublicKeyCredentialRequestOptions {}
export interface UploadKeysRequest {
identity_key: string
identity_key_ed25519: string
signed_prekey: string
signed_prekey_signature: string
one_time_prekeys: string[]
}
export interface UploadKeysResponse {
status: string
message: string
}
export interface CreateRoomRequest {
participant_id: string
room_type?: 'direct' | 'group' | 'ephemeral'
}
export interface RoomListResponse {
rooms: Room[]
}
export interface UserSearchRequest {
query: string
limit?: number
}
export interface UserSearchResponse {
users: User[]
}
export const api = {
root: {
getStatus: (): Promise<RootResponse> => get<RootResponse>('/'),
getHealth: (): Promise<HealthResponse> => get<HealthResponse>('/health'),
},
users: {
search: (
data: UserSearchRequest,
signal?: AbortSignal
): Promise<UserSearchResponse> =>
post<UserSearchResponse>('/auth/users/search', data, signal),
},
auth: {
beginRegistration: (
data: RegistrationBeginRequest,
signal?: AbortSignal
): Promise<WebAuthnOptionsResponse> =>
post<WebAuthnOptionsResponse>('/auth/register/begin', data, signal),
completeRegistration: (
data: RegistrationCompleteRequest,
signal?: AbortSignal
): Promise<User> => post<User>('/auth/register/complete', data, signal),
beginAuthentication: (
data: AuthenticationBeginRequest,
signal?: AbortSignal
): Promise<WebAuthnOptionsResponse> =>
post<WebAuthnOptionsResponse>('/auth/authenticate/begin', data, signal),
completeAuthentication: (
data: AuthenticationCompleteRequest,
signal?: AbortSignal
): Promise<User> => post<User>('/auth/authenticate/complete', data, signal),
me: (signal?: AbortSignal): Promise<User> => get<User>('/auth/me', signal),
logout: (signal?: AbortSignal): Promise<undefined> =>
post<undefined>('/auth/logout', undefined, signal),
},
encryption: {
getPrekeyBundle: (
userId: string,
signal?: AbortSignal
): Promise<PreKeyBundle> =>
get<PreKeyBundle>(`/encryption/prekey-bundle/${userId}`, signal),
uploadKeys: (
userId: string,
keys: UploadKeysRequest,
signal?: AbortSignal
): Promise<UploadKeysResponse> =>
post<UploadKeysResponse>(`/encryption/upload-keys/${userId}`, keys, signal),
},
rooms: {
list: (signal?: AbortSignal): Promise<RoomListResponse> =>
get<RoomListResponse>('/rooms', signal),
create: (data: CreateRoomRequest, signal?: AbortSignal): Promise<Room> =>
post<Room>('/rooms', data, signal),
get: (roomId: string, signal?: AbortSignal): Promise<Room> =>
get<Room>(`/rooms/${encodeURIComponent(roomId)}`, signal),
delete: (roomId: string, signal?: AbortSignal): Promise<undefined> =>
del<undefined>(`/rooms/${encodeURIComponent(roomId)}`, signal),
getMessages: (
roomId: string,
limit: number = 50,
offset: number = 0,
signal?: AbortSignal
): Promise<{
messages: Array<{
id: string
room_id: string
sender_id: string
sender_username: string
ciphertext: string
nonce: string
header: string
created_at: string
}>
has_more: boolean
}> =>
get(
`/rooms/${encodeURIComponent(roomId)}/messages?limit=${limit}&offset=${offset}`,
signal
),
},
}
export { request, get, post, put, del }
export type { RequestOptions, ApiClientConfig }

View File

@ -0,0 +1,92 @@
/**
* Base64URL encoding/decoding utilities for WebAuthn and encryption
* Uses URL-safe alphabet without padding (per RFC 4648)
*/
export function base64UrlEncode(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
const base64 = btoa(binary)
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
export function base64UrlDecode(str: string): Uint8Array {
let base64 = str.replace(/-/g, '+').replace(/_/g, '/')
const paddingNeeded = (4 - (base64.length % 4)) % 4
base64 += '='.repeat(paddingNeeded)
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
export function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
return base64UrlEncode(buffer)
}
export function base64UrlToArrayBuffer(str: string): ArrayBuffer {
return base64UrlDecode(str).buffer as ArrayBuffer
}
export function stringToUint8Array(str: string): Uint8Array {
const encoder = new TextEncoder()
return encoder.encode(str)
}
export function uint8ArrayToString(bytes: Uint8Array): string {
const decoder = new TextDecoder()
return decoder.decode(bytes)
}
export function hexEncode(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
export function hexDecode(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
}
return bytes
}
export function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0)
const result = new Uint8Array(totalLength)
let offset = 0
for (const arr of arrays) {
result.set(arr, offset)
offset += arr.length
}
return result
}
export function compareUint8Arrays(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) {
return false
}
let result = 0
for (let i = 0; i < a.length; i++) {
result |= a[i] ^ b[i]
}
return result === 0
}

View File

@ -0,0 +1,149 @@
/**
* Date formatting utilities for chat timestamps
*/
const SECONDS_IN_MINUTE = 60
const SECONDS_IN_HOUR = 3600
const SECONDS_IN_DAY = 86400
const SECONDS_IN_WEEK = 604800
const SHORT_WEEKDAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const SHORT_MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
export function formatMessageTime(dateString: string): string {
const date = new Date(dateString)
const hours = date.getHours()
const minutes = date.getMinutes()
const ampm = hours >= 12 ? 'PM' : 'AM'
const hoursMod12 = hours % 12
const displayHours = hoursMod12 === 0 ? 12 : hoursMod12
const displayMinutes = minutes.toString().padStart(2, '0')
return `${displayHours}:${displayMinutes} ${ampm}`
}
export function formatMessageDate(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffSeconds < SECONDS_IN_DAY && date.getDate() === now.getDate()) {
return 'Today'
}
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (
date.getDate() === yesterday.getDate() &&
date.getMonth() === yesterday.getMonth() &&
date.getFullYear() === yesterday.getFullYear()
) {
return 'Yesterday'
}
if (diffSeconds < SECONDS_IN_WEEK) {
return SHORT_WEEKDAY_NAMES[date.getDay()]
}
const month = SHORT_MONTH_NAMES[date.getMonth()]
const day = date.getDate()
if (date.getFullYear() === now.getFullYear()) {
return `${month} ${day}`
}
return `${month} ${day}, ${date.getFullYear()}`
}
export function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffSeconds < SECONDS_IN_MINUTE) {
return 'Just now'
}
if (diffSeconds < SECONDS_IN_HOUR) {
const minutes = Math.floor(diffSeconds / SECONDS_IN_MINUTE)
return `${minutes}m ago`
}
if (diffSeconds < SECONDS_IN_DAY) {
const hours = Math.floor(diffSeconds / SECONDS_IN_HOUR)
return `${hours}h ago`
}
if (diffSeconds < SECONDS_IN_WEEK) {
const days = Math.floor(diffSeconds / SECONDS_IN_DAY)
return `${days}d ago`
}
return formatMessageDate(dateString)
}
export function formatLastSeen(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffSeconds < SECONDS_IN_MINUTE) {
return 'Active now'
}
if (diffSeconds < SECONDS_IN_HOUR) {
const minutes = Math.floor(diffSeconds / SECONDS_IN_MINUTE)
return `Active ${minutes}m ago`
}
if (diffSeconds < SECONDS_IN_DAY) {
const hours = Math.floor(diffSeconds / SECONDS_IN_HOUR)
return `Active ${hours}h ago`
}
return `Last seen ${formatMessageDate(dateString)}`
}
export function formatTimestamp(dateString: string): string {
const date = new Date(dateString)
return date.toISOString()
}
export function isToday(dateString: string): boolean {
const date = new Date(dateString)
const now = new Date()
return (
date.getDate() === now.getDate() &&
date.getMonth() === now.getMonth() &&
date.getFullYear() === now.getFullYear()
)
}
export function isSameDay(dateString1: string, dateString2: string): boolean {
const date1 = new Date(dateString1)
const date2 = new Date(dateString2)
return (
date1.getDate() === date2.getDate() &&
date1.getMonth() === date2.getMonth() &&
date1.getFullYear() === date2.getFullYear()
)
}
export function getCurrentTimestamp(): string {
return new Date().toISOString()
}
export const formatTime = formatMessageTime

View File

@ -0,0 +1,8 @@
// ===================
// © AngelaMos | 2025
// index.ts
// ===================
export * from './api-client'
export * from './base64'
export * from './date'
export * from './validators'

View File

@ -0,0 +1,136 @@
/**
* Input validation utilities
*/
import {
DISPLAY_NAME_MAX_LENGTH,
DISPLAY_NAME_MIN_LENGTH,
MESSAGE_MAX_LENGTH,
USERNAME_MAX_LENGTH,
USERNAME_MIN_LENGTH,
} from '../config'
export interface ValidationResult {
valid: boolean
error?: string
}
const USERNAME_PATTERN = /^[a-zA-Z0-9_-]+$/
const DISPLAY_NAME_PATTERN = /^[\p{L}\p{N}\s\-_.]+$/u
export function validateUsername(username: string): ValidationResult {
const trimmed = username.trim()
if (trimmed.length === 0) {
return { valid: false, error: 'Username is required' }
}
if (trimmed.length < USERNAME_MIN_LENGTH) {
return {
valid: false,
error: `Username must be at least ${USERNAME_MIN_LENGTH} characters`,
}
}
if (trimmed.length > USERNAME_MAX_LENGTH) {
return {
valid: false,
error: `Username must be at most ${USERNAME_MAX_LENGTH} characters`,
}
}
if (!USERNAME_PATTERN.test(trimmed)) {
return {
valid: false,
error:
'Username can only contain letters, numbers, underscores, and hyphens',
}
}
return { valid: true }
}
export function validateDisplayName(displayName: string): ValidationResult {
const trimmed = displayName.trim()
if (trimmed.length === 0) {
return { valid: false, error: 'Display name is required' }
}
if (trimmed.length < DISPLAY_NAME_MIN_LENGTH) {
return {
valid: false,
error: `Display name must be at least ${DISPLAY_NAME_MIN_LENGTH} character`,
}
}
if (trimmed.length > DISPLAY_NAME_MAX_LENGTH) {
return {
valid: false,
error: `Display name must be at most ${DISPLAY_NAME_MAX_LENGTH} characters`,
}
}
if (!DISPLAY_NAME_PATTERN.test(trimmed)) {
return {
valid: false,
error: 'Display name contains invalid characters',
}
}
return { valid: true }
}
export function validateMessageContent(content: string): ValidationResult {
if (content.length === 0) {
return { valid: false, error: 'Message cannot be empty' }
}
if (content.length > MESSAGE_MAX_LENGTH) {
return {
valid: false,
error: `Message must be at most ${MESSAGE_MAX_LENGTH} characters`,
}
}
return { valid: true }
}
export function validateUUID(id: string): ValidationResult {
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
if (!uuidPattern.test(id)) {
return { valid: false, error: 'Invalid ID format' }
}
return { valid: true }
}
export function sanitizeInput(input: string): string {
return input
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
}
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) {
return str
}
return `${str.slice(0, maxLength - 3)}...`
}
export function normalizeWhitespace(str: string): string {
return str.replace(/\s+/g, ' ').trim()
}
export function isValidUrl(str: string): boolean {
try {
const url = new URL(str)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}

View File

@ -50,7 +50,7 @@ export default function Chat(): JSX.Element {
await cryptoService.initialize(currentUserId)
} catch {}
connectWebSocket()
await roomService.loadRooms(currentUserId)
await roomService.loadRooms()
}
})
@ -127,7 +127,7 @@ export default function Chat(): JSX.Element {
return
}
const room = await roomService.createRoom(currentUserId, targetUserId)
const room = await roomService.createRoom(targetUserId)
if (room) {
setActiveRoom(room.id)

View File

@ -1,7 +1,7 @@
/**
* WebAuthn authentication service
* Handles passkey registration and authentication flows
*/
// ===================
// © AngelaMos | 2026
// auth.service.ts
// ===================
import { cryptoService } from '../crypto'
import { api } from '../lib/api-client'
@ -192,7 +192,25 @@ export async function login(username?: string): Promise<User> {
return user
}
export function logout(): void {
export async function rehydrateSession(): Promise<User | null> {
try {
const user = await api.auth.me()
setCurrentUser(user)
await cryptoService.initialize(user.id)
return user
} catch {
storeLogout()
return null
}
}
export async function logout(): Promise<void> {
try {
await api.auth.logout()
} catch {
/* ignore network errors during logout */
}
await cryptoService.clearAllSessions()
storeLogout()
}
@ -236,6 +254,7 @@ export const authService = {
register,
login,
logout,
rehydrateSession,
isWebAuthnSupported,
isPlatformAuthenticatorAvailable,
isConditionalUIAvailable,

View File

@ -1,7 +1,7 @@
/**
* ©AngelaMos | 2025
* room.service.ts
*/
// ===================
// © AngelaMos | 2026
// room.service.ts
// ===================
import {
cryptoService,
@ -19,9 +19,9 @@ import {
} from '../stores'
import type { Message, Room } from '../types'
export async function loadRooms(userId: string): Promise<Room[]> {
export async function loadRooms(): Promise<Room[]> {
try {
const response = await api.rooms.list(userId)
const response = await api.rooms.list()
setRooms(response.rooms)
return response.rooms
} catch {
@ -30,13 +30,11 @@ export async function loadRooms(userId: string): Promise<Room[]> {
}
export async function createRoom(
creatorId: string,
participantId: string,
roomType: 'direct' | 'group' | 'ephemeral' = 'direct'
): Promise<Room | null> {
try {
const room = await api.rooms.create({
creator_id: creatorId,
participant_id: participantId,
room_type: roomType,
})
@ -64,7 +62,6 @@ export async function loadMessages(
const serverMessages = response.messages.reverse()
const newMessages: Message[] = []
const currentUserId = $userId.get()
for (const msg of serverMessages) {
@ -72,7 +69,7 @@ export async function loadMessages(
continue
}
let content = '[Encrypted - from another session]'
let content = '[Sent before this device joined - cannot decrypt]'
const isOwnMessage = msg.sender_id === currentUserId
if (isOwnMessage) {
@ -80,7 +77,7 @@ export async function loadMessages(
if (localCopy) {
content = localCopy.content
} else {
content = '[Your message - not stored locally]'
content = '[Sent from a different device]'
}
} else {
try {
@ -91,7 +88,7 @@ export async function loadMessages(
msg.header
)
} catch {
content = '[Encrypted - from another session]'
content = '[Sent before this device joined - cannot decrypt]'
}
}
@ -110,10 +107,7 @@ export async function loadMessages(
updated_at: msg.created_at,
}
if (
!content.startsWith('[Encrypted') &&
!content.startsWith('[Your message')
) {
if (!content.startsWith('[Sent')) {
void saveDecryptedMessage(decryptedMessage)
}

View File

@ -1,7 +1,7 @@
/**
* Authentication state store
* Manages current user and authentication status
*/
// ===================
// © AngelaMos | 2026
// auth.store.ts
// ===================
import { persistentAtom } from '@nanostores/persistent'
import { atom, computed } from 'nanostores'

View File

@ -1,12 +1,11 @@
// ===================
// © AngelaMos | 2025
// © AngelaMos | 2026
// index.ts
// ===================
export * from './auth.store'
export * from './messages.store'
export * from './presence.store'
export * from './rooms.store'
export * from './session.store'
export * from './settings.store'
export * from './typing.store'
export * from './ui.store'

View File

@ -1,79 +0,0 @@
/**
* Session tokens store with persistence
* Stores authentication tokens in localStorage
*/
import { persistentAtom, persistentMap } from '@nanostores/persistent'
interface SessionTokens {
accessToken: string
refreshToken: string
expiresAt: number
}
const DEFAULT_TOKENS: SessionTokens = {
accessToken: '',
refreshToken: '',
expiresAt: 0,
}
export const $sessionTokens = persistentMap<SessionTokens>(
'chat:session:',
DEFAULT_TOKENS,
{
encode: JSON.stringify,
decode: JSON.parse,
}
)
export const $lastActivity = persistentAtom<string>('chat:last_activity', '', {
encode: String,
decode: String,
})
export function setSessionTokens(
accessToken: string,
refreshToken: string,
expiresInSeconds: number
): void {
const expiresAt = Date.now() + expiresInSeconds * 1000
$sessionTokens.set({
accessToken,
refreshToken,
expiresAt,
})
updateLastActivity()
}
export function clearSessionTokens(): void {
$sessionTokens.set(DEFAULT_TOKENS)
}
export function isSessionValid(): boolean {
const tokens = $sessionTokens.get()
if (tokens.accessToken === '') {
return false
}
return tokens.expiresAt > Date.now()
}
export function getAccessToken(): string | null {
if (!isSessionValid()) {
return null
}
return $sessionTokens.get().accessToken
}
export function getRefreshToken(): string | null {
const tokens = $sessionTokens.get()
return tokens.refreshToken !== '' ? tokens.refreshToken : null
}
export function updateLastActivity(): void {
$lastActivity.set(new Date().toISOString())
}
export function getLastActivity(): Date | null {
const activity = $lastActivity.get()
return activity !== '' ? new Date(activity) : null
}

View File

@ -94,11 +94,19 @@ const errorMessageHandler: MessageHandler<ErrorMessageWS> = (message) => {
}
const roomCreatedHandler: MessageHandler<RoomCreatedWS> = (message) => {
const participants: Room['participants'] = message.participants.map((p) => ({
user_id: String(p.user_id ?? ''),
username: String(p.username ?? ''),
display_name: String(p.display_name ?? ''),
role: (p.role as 'owner' | 'admin' | 'member' | undefined) ?? 'member',
joined_at: String(p.joined_at ?? ''),
}))
const room: Room = {
id: message.room_id,
type: message.room_type as 'direct' | 'group' | 'ephemeral',
name: message.name,
participants: message.participants,
name: message.name ?? undefined,
participants,
unread_count: 0,
is_encrypted: message.is_encrypted,
created_at: message.created_at,

View File

@ -1,10 +1,10 @@
// ===================
// ©AngelaMos | 2025
// © AngelaMos | 2026
// websocket-manager.ts
// ===================
import { atom, computed } from 'nanostores'
import { WS_HEARTBEAT_INTERVAL, WS_RECONNECT_DELAY, WS_URL } from '../config'
import { $userId } from '../stores'
import { $isAuthenticated } from '../stores'
import type {
WSMessage,
WSOutgoingEncryptedMessage,
@ -60,8 +60,7 @@ class WebSocketManager {
private fatalError = false
connect(): void {
const userId = $userId.get()
if (!userId) {
if (!$isAuthenticated.get()) {
$lastError.set('Cannot connect: User not authenticated')
return
}
@ -78,7 +77,7 @@ class WebSocketManager {
$connectionStatus.set('connecting')
$lastError.set(null)
const wsUrl = `${WS_URL}/ws?user_id=${userId}`
const wsUrl = `${WS_URL}/ws`
this.ws = new WebSocket(wsUrl)
this.ws.onopen = this.handleOpen.bind(this)

View File

@ -1,5 +1,5 @@
// ===================
// © AngelaMos | 2025
// © AngelaMos | 2026
// vite.config.ts
// ===================
@ -44,4 +44,8 @@ export default defineConfig({
},
},
},
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
})

View File

@ -83,14 +83,23 @@ typecheck: mypy
[group('test')]
pytest *ARGS:
pytest backend/tests {{ARGS}}
cd backend && uv run pytest {{ARGS}}
[group('test')]
test: pytest
test-frontend *ARGS:
cd frontend && pnpm test {{ARGS}}
[group('test')]
test: pytest test-frontend
[group('test')]
test-cov:
pytest backend/tests --cov=backend/app --cov-report=term-missing --cov-report=html
cd backend && uv run pytest --cov=app --cov-report=term-missing --cov-report=html
[group('dev')]
dev-reset:
docker compose -f dev.compose.yml down -v
@echo "Volumes wiped. Run 'just dev-up' to start fresh."
# =============================================================================
# CI / Quality