From 6fd5f3d393a1ff40b907eb68351e41318a0f30f7 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 29 Apr 2026 02:30:10 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix(encrypted-p2p-chat):=20apply=20audit=20?= =?UTF-8?q?findings=20=E2=80=94=20true=20E2EE,=20session=20auth,=20spec-co?= =?UTF-8?q?rrect=20crypto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../advanced/encrypted-p2p-chat/.gitignore | 39 +- .../encrypted-p2p-chat/backend/alembic/env.py | 7 +- .../backend/app/api/auth.py | 118 +++- .../backend/app/api/encryption.py | 79 +-- .../backend/app/api/rooms.py | 324 +++++++--- .../backend/app/api/websocket.py | 69 ++- .../encrypted-p2p-chat/backend/app/config.py | 37 +- .../backend/app/core/dependencies.py | 107 ++++ .../backend/app/core/encryption/__init__.py | 0 .../app/core/encryption/double_ratchet.py | 419 ------------- .../app/core/encryption/x3dh_manager.py | 353 ----------- .../backend/app/core/exception_handlers.py | 80 +-- .../backend/app/core/exceptions.py | 28 +- .../app/core/passkey/passkey_manager.py | 12 +- .../backend/app/core/redis_manager.py | 176 +++--- .../backend/app/core/surreal_manager.py | 54 ++ .../backend/app/core/websocket_manager.py | 20 +- .../encrypted-p2p-chat/backend/app/factory.py | 42 +- .../backend/app/models/IdentityKey.py | 16 +- .../backend/app/models/OneTimePrekey.py | 13 +- .../backend/app/models/RatchetState.py | 68 -- .../backend/app/models/SignedPrekey.py | 12 +- .../backend/app/models/SkippedMessageKey.py | 51 -- .../backend/app/models/User.py | 24 +- .../backend/app/models/__init__.py | 8 +- .../backend/app/services/auth_service.py | 165 ++--- .../backend/app/services/message_service.py | 406 +----------- .../backend/app/services/prekey_service.py | 301 +-------- .../backend/app/services/websocket_service.py | 55 +- .../encrypted-p2p-chat/backend/pyproject.toml | 3 +- .../backend/tests/conftest.py | 127 +--- .../backend/tests/test_auth_service.py | 25 +- .../backend/tests/test_encryption.py | 165 ----- .../backend/tests/test_message_service.py | 135 ---- .../backend/tests/test_prekey_service.py | 155 +++++ .../backend/tests/test_session_auth.py | 101 +++ .../backend/tests/test_x3dh.py | 130 ---- .../encrypted-p2p-chat/backend/uv.lock | 93 +++ .../encrypted-p2p-chat/frontend/package.json | 7 +- .../frontend/pnpm-lock.yaml | 582 ++++++++++++++++++ .../public/assets/android-chrome-192x192.png | Bin 0 -> 88092 bytes .../public/assets/android-chrome-512x512.png | Bin 0 -> 510030 bytes .../public/assets/apple-touch-icon.png | Bin 0 -> 78997 bytes .../frontend/public/assets/favicon-16x16.png | Bin 0 -> 985 bytes .../frontend/public/assets/favicon-32x32.png | Bin 0 -> 3328 bytes .../frontend/public/assets/favicon.ico | Bin 0 -> 15406 bytes .../frontend/public/assets/site.webmanifest | 1 + .../frontend/src/crypto/crypto-service.ts | 4 +- .../src/crypto/double-ratchet.test.ts | 121 ++++ .../frontend/src/crypto/double-ratchet.ts | 33 +- .../frontend/src/crypto/index.ts | 15 +- .../frontend/src/crypto/primitives.ts | 12 +- .../frontend/src/crypto/x3dh.test.ts | 133 ++++ .../frontend/src/crypto/x3dh.ts | 5 +- .../encrypted-p2p-chat/frontend/src/index.tsx | 7 + .../frontend/src/lib/api-client.ts | 266 ++++++++ .../frontend/src/lib/base64.ts | 92 +++ .../frontend/src/lib/date.ts | 149 +++++ .../frontend/src/lib/index.ts | 8 + .../frontend/src/lib/validators.ts | 136 ++++ .../frontend/src/pages/Chat.tsx | 4 +- .../frontend/src/services/auth.service.ts | 29 +- .../frontend/src/services/room.service.ts | 26 +- .../frontend/src/stores/auth.store.ts | 8 +- .../frontend/src/stores/index.ts | 3 +- .../frontend/src/stores/session.store.ts | 79 --- .../src/websocket/message-handlers.ts | 12 +- .../src/websocket/websocket-manager.ts | 9 +- .../frontend/vite.config.ts | 6 +- PROJECTS/advanced/encrypted-p2p-chat/justfile | 15 +- 70 files changed, 2879 insertions(+), 2900 deletions(-) create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/dependencies.py delete mode 100644 PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/encryption/__init__.py delete mode 100644 PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/encryption/double_ratchet.py delete mode 100644 PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/encryption/x3dh_manager.py delete mode 100644 PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/RatchetState.py delete mode 100644 PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/SkippedMessageKey.py delete mode 100644 PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_encryption.py delete mode 100644 PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_message_service.py create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_prekey_service.py create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_session_auth.py delete mode 100644 PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_x3dh.py create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/android-chrome-192x192.png create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/android-chrome-512x512.png create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/apple-touch-icon.png create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/favicon-16x16.png create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/favicon-32x32.png create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/favicon.ico create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/site.webmanifest create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/double-ratchet.test.ts create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/x3dh.test.ts create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/api-client.ts create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/base64.ts create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/date.ts create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/index.ts create mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/validators.ts delete mode 100644 PROJECTS/advanced/encrypted-p2p-chat/frontend/src/stores/session.store.ts diff --git a/PROJECTS/advanced/encrypted-p2p-chat/.gitignore b/PROJECTS/advanced/encrypted-p2p-chat/.gitignore index c2f5ab4c..421abf61 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/.gitignore +++ b/PROJECTS/advanced/encrypted-p2p-chat/.gitignore @@ -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/ diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/alembic/env.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/alembic/env.py index 7a95d325..3ebd77b6 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/alembic/env.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/alembic/env.py @@ -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 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/auth.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/auth.py index 341890ce..5158464c 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/auth.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/auth.py @@ -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] ) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/encryption.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/encryption.py index 355e7504..da5c9b75 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/encryption.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/encryption.py @@ -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} diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/rooms.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/rooms.py index f856d95b..ec78aa4d 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/rooms.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/rooms.py @@ -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) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/websocket.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/websocket.py index 2cd17444..0145c0c7 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/websocket.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/api/websocket.py @@ -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) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/config.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/config.py index 72bc5e5c..944cdc7d 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/config.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/config.py @@ -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" diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/dependencies.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/dependencies.py new file mode 100644 index 00000000..2207c32e --- /dev/null +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/dependencies.py @@ -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 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/encryption/__init__.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/encryption/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/encryption/double_ratchet.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/encryption/double_ratchet.py deleted file mode 100644 index 01bdb656..00000000 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/encryption/double_ratchet.py +++ /dev/null @@ -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() diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/encryption/x3dh_manager.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/encryption/x3dh_manager.py deleted file mode 100644 index cf3b8dfb..00000000 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/encryption/x3dh_manager.py +++ /dev/null @@ -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() diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/exception_handlers.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/exception_handlers.py index d963ef97..79382f62 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/exception_handlers.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/exception_handlers.py @@ -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) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/exceptions.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/exceptions.py index 20b7b0e7..2959da11 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/exceptions.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/exceptions.py @@ -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 - """ diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/passkey/passkey_manager.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/passkey/passkey_manager.py index ef2807a6..6c2a2aa3 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/passkey/passkey_manager.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/passkey/passkey_manager.py @@ -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") diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/redis_manager.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/redis_manager.py index 2a012b60..6d763846 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/redis_manager.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/redis_manager.py @@ -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() diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/surreal_manager.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/surreal_manager.py index 1335666e..9f50066d 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/surreal_manager.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/surreal_manager.py @@ -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 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/websocket_manager.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/websocket_manager.py index 68de96a6..8a267cf9 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/websocket_manager.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/core/websocket_manager.py @@ -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: diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/factory.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/factory.py index c557105a..516dc8d9 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/factory.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/factory.py @@ -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, diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/IdentityKey.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/IdentityKey.py index 2be452b6..64036723 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/IdentityKey.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/IdentityKey.py @@ -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: """ diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/OneTimePrekey.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/OneTimePrekey.py index 00c421cb..d86451c8 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/OneTimePrekey.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/OneTimePrekey.py @@ -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 ) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/RatchetState.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/RatchetState.py deleted file mode 100644 index 0cf5e53b..00000000 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/RatchetState.py +++ /dev/null @@ -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"" diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/SignedPrekey.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/SignedPrekey.py index d93fda81..e2888c70 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/SignedPrekey.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/SignedPrekey.py @@ -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) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/SkippedMessageKey.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/SkippedMessageKey.py deleted file mode 100644 index d484bf75..00000000 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/SkippedMessageKey.py +++ /dev/null @@ -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"" - ) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/User.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/User.py index 708cbe15..3cb4c6c1 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/User.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/User.py @@ -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: """ diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/__init__.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/__init__.py index 7408f730..9c8ad7a5 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/__init__.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/models/__init__.py @@ -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", diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/auth_service.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/auth_service.py index 1f9cc0ce..f112a264 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/auth_service.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/auth_service.py @@ -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 "" + ) 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() diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/message_service.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/message_service.py index eadaa914..0448538b 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/message_service.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/message_service.py @@ -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() diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/prekey_service.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/prekey_service.py index 52ba9040..9e0aa593 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/prekey_service.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/prekey_service.py @@ -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() diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/websocket_service.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/websocket_service.py index 5e1e554f..b5366127 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/websocket_service.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/app/services/websocket_service.py @@ -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, diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/pyproject.toml b/PROJECTS/advanced/encrypted-p2p-chat/backend/pyproject.toml index 1a3fb57e..01af7a70 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/pyproject.toml +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/pyproject.toml @@ -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 = [ diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/conftest.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/conftest.py index bd8944f2..776f0255 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/conftest.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/conftest.py @@ -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" diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_auth_service.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_auth_service.py index 41d379ae..7146732a 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_auth_service.py +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_auth_service.py @@ -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 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_encryption.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_encryption.py deleted file mode 100644 index b6089d49..00000000 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_encryption.py +++ /dev/null @@ -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 - ) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_message_service.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_message_service.py deleted file mode 100644 index adaa1b0a..00000000 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_message_service.py +++ /dev/null @@ -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 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_prekey_service.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_prekey_service.py new file mode 100644 index 00000000..74fd129b --- /dev/null +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_prekey_service.py @@ -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" diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_session_auth.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_session_auth.py new file mode 100644 index 00000000..d4c81632 --- /dev/null +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_session_auth.py @@ -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 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_x3dh.py b/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_x3dh.py deleted file mode 100644 index 90ac8e8e..00000000 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/tests/test_x3dh.py +++ /dev/null @@ -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 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/uv.lock b/PROJECTS/advanced/encrypted-p2p-chat/backend/uv.lock index 14a9dd35..01bff7ec 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/uv.lock +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/uv.lock @@ -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" diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/package.json b/PROJECTS/advanced/encrypted-p2p-chat/frontend/package.json index 5a013ea5..84a37ba2 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/package.json +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/package.json @@ -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" } } diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/pnpm-lock.yaml b/PROJECTS/advanced/encrypted-p2p-chat/frontend/pnpm-lock.yaml index 6b51a3af..8cf32fb3 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/pnpm-lock.yaml +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: vite-plugin-solid: specifier: ^2.11.10 version: 2.11.10(solid-js@1.9.11)(vite@7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)) + vitest: + specifier: ^2.1.5 + version: 2.1.9(@types/node@25.2.3)(lightningcss@1.31.1) packages: @@ -181,24 +184,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.4.2': resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.2': resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.4.2': resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.4.2': resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==} @@ -212,102 +219,204 @@ packages: cpu: [x64] os: [win32] + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} @@ -320,6 +429,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} @@ -332,6 +447,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} @@ -344,24 +465,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -484,66 +629,79 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -618,24 +776,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.0': resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.0': resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.0': resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.0': resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} @@ -758,6 +920,35 @@ packages: resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -802,6 +993,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -853,6 +1048,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -872,10 +1071,18 @@ packages: caniuse-lite@1.0.30001770: resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -920,6 +1127,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -965,6 +1176,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -981,6 +1195,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1055,10 +1274,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1406,24 +1632,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -1448,6 +1678,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1538,6 +1771,13 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1705,6 +1945,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + solid-js@1.9.11: resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} @@ -1717,6 +1960,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -1755,10 +2004,28 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -1813,6 +2080,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-plugin-solid@2.11.10: resolution: {integrity: sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw==} peerDependencies: @@ -1823,6 +2095,37 @@ packages: '@testing-library/jest-dom': optional: true + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@7.3.2: resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1871,6 +2174,31 @@ packages: vite: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -1892,6 +2220,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2051,81 +2384,150 @@ snapshots: '@biomejs/cli-win32-x64@2.4.2': optional: true + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true @@ -2488,6 +2890,46 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.0 + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.2.3)(lightningcss@1.31.1))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@25.2.3)(lightningcss@1.31.1) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -2549,6 +2991,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -2598,6 +3042,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2619,11 +3065,21 @@ snapshots: caniuse-lite@1.0.30001770: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + check-error@2.1.3: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2666,6 +3122,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -2760,6 +3218,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -2781,6 +3241,32 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -2918,8 +3404,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -3267,6 +3759,8 @@ snapshots: lodash.merge@4.6.2: {} + loupe@3.2.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -3361,6 +3855,10 @@ snapshots: path-key@3.1.1: {} + pathe@1.1.2: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -3521,6 +4019,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + solid-js@1.9.11: dependencies: csstype: 3.2.3 @@ -3538,6 +4038,10 @@ snapshots: source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -3586,11 +4090,21 @@ snapshots: tapable@2.3.0: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -3664,6 +4178,24 @@ snapshots: dependencies: punycode: 2.3.1 + vite-node@2.1.9(@types/node@25.2.3)(lightningcss@1.31.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@25.2.3)(lightningcss@1.31.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)): dependencies: '@babel/core': 7.29.0 @@ -3677,6 +4209,16 @@ snapshots: transitivePeerDependencies: - supports-color + vite@5.4.21(@types/node@25.2.3)(lightningcss@1.31.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.60.1 + optionalDependencies: + '@types/node': 25.2.3 + fsevents: 2.3.3 + lightningcss: 1.31.1 + vite@7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: esbuild: 0.27.7 @@ -3695,6 +4237,41 @@ snapshots: optionalDependencies: vite: 7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1) + vitest@2.1.9(@types/node@25.2.3)(lightningcss@1.31.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.2.3)(lightningcss@1.31.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@25.2.3)(lightningcss@1.31.1) + vite-node: 2.1.9(@types/node@25.2.3)(lightningcss@1.31.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.2.3 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -3740,6 +4317,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} yallist@3.1.1: {} diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/android-chrome-192x192.png b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..16ed27372000f85ddfb473b23dcd1c82e1cf8c1c GIT binary patch literal 88092 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 literal 0 HcmV?d00001 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/android-chrome-512x512.png b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..39db81a49a3212e44acd7fa682172eacc9367ff9 GIT binary patch literal 510030 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

l;?%S$^yC#s$4H(-l-E`uh?z;6v6L%cn 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%0AO0m;5G&-huG7XI> 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
$X+#-s<;+ z{?!1TP@qF5@V+~)CA3~-4VNCh&_sm3^o7ssjW^!Z`r57v9MbDI?#RCjk*1lP*VeZ- z|jiKGy9%^%A zptI+n)XeOX9)I>JoyHLwZLO;ylJe${eyH91*Y(N|zOBd39@EC!j@EDOidE|FwRL^} zxfgWv_HBLWV;`13+uPfm^!IVb)^!5|euFmUq@@dIpVY-ipH-gCVeF4-<>X^JdHz{_ z=o5chGmGccnLVc6p>*uTDT!0zEw0oKi28K$4tt&{IAJ&;AM7x9nrnnS_S;=$AyMXg zA0>|-mPAMgIPh|Zm_94VPNIWM5n9d8&guBdN^>nz5PAI)*Bw``-q5SBUD0p+`QOl0 zzY6K~_4JcZ>CD-)@O4mx-yB@V9TMcyQ!s$t1iPXuOGM6tO19TcZ{N2Iv zEa9suxNdPBl~(e zPaUTC_!$1~EmuY3rS$!X*e*)y}lHfD$A6*P!3s6w{%Gd$rZwqa0T6ba1? zI@U~X5=8JnBw`qQhZ@5ZEuc?oO{#$oCi~hY>@=`KJ(Tvs$#d$ioYfASTqijpPic(8 ztMayLl%I!u6E<3$79t8F;PE-I{Yq4;#?uyaxRa1DMg*bY7_rEJqBHu5Q--HgsQe3d zgg<9`ZRKeIxAMU_f?0hNrGQX5m_^wGsDtU1ygV*%cLy~{QaFR zZQ?kMSuf;g%#OOB~U_#Lbdv{J?{LksbC)zM2QqDDG!PItqx(h8n_BjO^(b*N)T5W4#o<}d9*Td&2|HS< zK6N%>^l_mb?$8ei-xfMhWkbcREl`<>NVb+sK9ASEr%yccB|IT-oEawt;>J=;TzxjrZ&NQX1=EcURFD55TdpY z4q)3tufFn%ju8bgA}OQ2k$TYZ`i(34;4>f8<4-)ToPQtTx?w=wg%iiMON8X<(`OZM zcIRh0GRySg4}V&{)||R|SN(1q`qgTqPfo5ZYJbz^Mw%sJvpj!H!CZEOUjE_By8QZ^ z^8aaX{rY{a<4|aOp&ox{kKFG7YX8(T3lGvsgoygM2;grV}0+1A8YmW zN&Us&`rCTq>8JJbk6ys(O!a}M9@DEYy{J!p=mW|_C95YIA{%oH^J=A;YCmlkHM}6z z3hFRdJ>eba#LS?NDa8TED4%e62$WSAA5YUHQHi%dLjSnYb8wk-fcYeoT*o=+$m8|i zwh9HjU9iBh+`a^GNkUMXW$Jah>Y&qFS+0;o!Q2fPdB-JTZpX_V6HjBL4Y-Ynrnv*U zPc&`Fwd)iL@N}49%f_suWPJrZFhMpe4y9#Tu@BIo7H9{FpI%;}?<*8>2qvGy*J5Te zGYK8x`XVqjJt>nC@UtLQVzZGeHDKt4in5wE^piBjCDNGYLOIZe^2)Q;v=uwjOhXrM z6M1p^gtA7CqFjfKS&w9*N!2A^yM@rF#g%l-0zJ7rfi zGxIBoY}N?nK0~PP@h3m1Gmkv3vlkxI%84_YSz3{CI_-96|HM2;3(R~WCMR-aL#N>H zI`a62lrW*gnA1iVX6fPS`sR9_gV!DD(qzpJ$2WUt>I~^%X3gAYZ>L^fvk{(_FiM4x z6bLR0VgpPuBobvMOyWX2LAWT8 z0bEdrL?RqFpTYA7`}^|0u(r#c>KI`dBg~Gcuf<`EB65R2#t>9RNwfon%_-5_`20R5 z3s4n|lVL?Y1_W+%0rmC!dd*BZhTRp0mw~=K?`@{*D;e8QfnGPq^+LmcGL;BmOnk+X znPdHSst1pm$wB}~&DfV(GBxRRK2PgrAKxx-%3O-%We&99-OK4L-;*X!{q$3_3AG`m zs*vp(gJtS+5{g->8XVks9YDv(Ue*dq^9-Xn#E>4S-^x`V?CRdt*EQH&(+nZ;AgMgq z(H_?*hEtP4#{e(&x*eriq7h1Q7bn5t0K;FizO{{YRW(1(^ZENT7`C~F*T?7x-}iQ_CvFUs#vDU8mj8;Am;2t% zWgWFD)9Ix-&A=N2jDCeu9uf_knVZwX(vq&c^%hhX5uw?}2^eFjR&gBCep_3^J+-?n zl{jg~PONGP$H!QV|7zaLFTJF1f9orH@|nlAKi<_4x?R71UEliFbDG5%Cn@@@idsW< zPM^Q1o&B-)p-+eLT4~Vg!koVJ2fwe|>$i34{3-eWTDM0u?aYM>$~w7jt=-WM4o4qH zYL#oS?SpOIp=@(&O)3XU>JiTOriQ!gDuxFtkhxBlYk7GETDNelW)zb|vvc#!mpqds zgSKt8abAX;_OmpV!G{xYiWaErYJ|@C(1$*(b^o6bDO+8f*Id7&A+xHdHwqCt* zQ(yS%xAgjJZ)$=QIft|6^8D({Ki0{U$0YX6>A$-IUF22=2P zy?hFhxkCuZUEQE+W)D*{lbMMhd`#ZX+dDo!u6R5x(1G6D>m4W-y=3M;{p}FEpX2Fm ze4NA5pZpV&{^SPJTK7{bHoSr$C9sb&8)Ix!4E*fEl0urxVycDZ6FPqOQ7s)mr>wJ} zAs*Bi=GjO1tE7Y7$<^f~*IziMW3)SS{!tjFGo9u=Q(usS+1m>?dpkev4rG|-ewAB))3O{F!ge?!~Ap{pW6xQsT0}Enho{(22l4y?%E4Za$Fx3+iLs zJ^F*YRjw#&O9(MfL!0z0=qorf3Dv1QuDC81_dw^o{ zb^3b8@PzjnqeNIr6L$7Fyxi%KF|Sl(cq)0hw{5|1o=PTvd>k{A*&%q@6aqL0%9}I< z`IuhkI64jwfi+C?Uhgn{uQ8}K{QD5V$AUfFM{nx@6;yJP5^D{0sZ^#wyZ+p)6cW`I zHNivQ+}f4P$pD8iZ)X?pKW#Ykx$ z1iL}kC_m+A?l7cLDUO6+3*EhSTeolAV2=xdf{MvdE$WAZeeLdTLx(X2`9MjYDW(a0 z($VFsH}%4gUQ(%A9h{61BtsF8$3vAci_?5aL}HCQc~{Ca3ti?05AbC`O>iPgz=(VV z_+fw}aR78MaJ_a%%gf91F=C9mdt*&o8+)ophT+Kydmpj`ZC3UV|pf`d9z;e@nmq z7k*PSvt9MOxn6te2in1@-W_de;9vTMMrA@DB6QDw{6qSUzxo&S>0kdfefpPwS-<+5 zzo{ob@{E#Jf+N_H5fQDu-}W7Rfpy)v@uqS%=i?vzs4n1aoH>0ChkH}^?`^2vozdd* zs_K|P+Nv?y{r;?4F~d3RDub7jT6*fqr}gun{ss0p)sEQEC!D;CFnd5;@eNgqjNqFBHD&Vn(Ky@Q+K?Ns+2`6h&ny0x$`4$Y-YPWUj^ck(5IH{yF ziz3XZN)%$I0;OjmVFPQ8@#NVvJnu2}rV+{_NxS4h1EneX);svjKn>^+%uIY5#;ail z-%MUa=|c!KsBuob^lKJn000mGNklzv8{bH60gT7G)udB`b4-Zzd0qk2wvE@Zf`jk_b-p z9XFd@A}9kz=;KR|77_LdZ4$W$KSl`-IEf1=j# z$99IVIN7>m;N=BZHZz=3OQHy398dT5cI1(UF^+>9qPni-4p7Nn8Z>Npz?e^xnU|QP zDflSVS)?dw0Msd@o^i;qkF`Ym`XNXqc>B0M*CEid2594LE9MbE5w!Jk@h~Poy-Zj~ z_XB1-eHlD4zp#vP>Z#N2U|4%fQIL1X=341cy z$0@+!^XsrA)~YbX1%f>(Ds{U(oj-p;tER zepxrKUz2}WI+W3S)U{9IopVc}9^rCCtg zN)>^;og>FX-M@cZTO0QjO^Py!6VZ_CqMh|k6vtvT``5u? zHTI>CQ9BL}Il3f?31i;V>sK!;o$aV>Wh!VhoK$k&G&8m67cnxe*;%ceJgFI+T#ukd z1uY=&v*@8uef*;wERLarYK0`Ce@dM>cR@=ltIFF^c}x2IS3a*{tW;--&OCZfbMv$M z#@C-yf9AM;_1FHAwsuCk^2VE5<{IVp&0G37W4?(aQQ*Y(xZa5ERI#7x*M8&I^m9M= zagET6Axkw?DDR}uAW2*w*Hyl{O9z)|3Ca|>Ts2M z`s6A7;%9zAzxe4-YY`dV+u70f{zO@)&+T4Ni#7$i@$pL+b@ucrc)!rU{J;AXX4(~3PKjuFKpZll+N0+FY_u?V&Cef}|P4X@P!3vzHP|Z9vQw#yR#wLy7FkjQg zaS)HEN1+DaKsz&&=WE*fST*Y}G1Lyu!=l`QRu9HD1?G3i(4SLqJ)1$(JWJKia~#x6 z0R%m2+Snt;^>ot6b)kYeCY1S{O`dmOWyyMc-U;ts?{tQPWo8YCr@&53(2WD^2FOq% zc2_bTKAt)pz8(Gyhsk^YY>3QW@KFI_BKH<0ClF`Gicr^hG|-Sx*2^#dP}?Z$m}W{5 zbW2i*k|=^)W~nkoC0Iq|LIqy;Xt1lz`}eeQcTJlZiLx4DG$svH2wBM;O@)v+lq^_m zc1fA15saP4W{}0yEG{q)SvESbC#6yZe|~OW8Tb@99W`@TD9zd#U5r9w3nmKz@YEz@ zD@mNt6>D{5}0WfGpcNg%Gf)hq1N8$Gu=Ve){0dyY2 zHo@~4ULVKO1a(w1exjHp3eY#isM`4f$E^Y;D1-uIHHIHrX{y&>eNAUhomHB&6w4t0 zH)d>SY6quH363=*XL$spLL=_y#yFTI?OHHP${K6rU^WEKm-BHBth>AdX-fz zKFxb%mKeb=M43faF@=QYi9k_K3}d$1Y90QgtBf(GKqa4wjh?`J1w!R>z!DO)D??z< zE5JXKhDtNqt7fRaH;FhJi+9A{S`mPSP_BGlc%8ixp*~-ds_BBx~BF4VZ)+UZl zAtmiODVxKfNPD~c5^GM<2tRZcIr4=lXf)Qr-au`Pp*xuq1bzW00Qko@kyOFE=>pR@NUzM-z#JSUY>I2U(ejhm;aTT_yt(~D-GcBDwv!cb5D;i}{ zKf*wN@1^I}o#|NM}}8bO{II7cj0@Z{5=0`d9vC z{lcI5WnKKxc`cl1>6KTW)6>s9q50KS1!y`x7%8A@ULsm>@!~~&^wXcvM?U>gef;M> zslWDHe-qkO`cpsm8MU%p^D}*Y?w5Z_-A>fM@VEb}{`PPE75(Nv^JjG7!f8lVYJKC5 zE^f}Ee9@iNhK**(!eDH(HySW$~JyYw$&wN1t=y!it zH|}lf^dlE_2Oij3zo#c3e^krM^Geg2>z_UKakQ$cWUe4XPjBz+DFrW=voZT*78F91 zWHK`xgjy~aE<4VfG0T+ZE%pdt&uGlHAF$us&~WUisjh2^gOVg^QZrNY)XmR_KYZL0 zUM|rI(?TiwI8ZLi9L!8+hfqV`BngT!2$bc~haA~#C(w>GL!cZw zoes93tu{R5jzWg)H8i6in9BFb=dB*-r4fe&H}oSMts#d62X_i~cK6U9*ctSez!{Y! z&};Da6qPO0+mGY)_S;X$#TXe6R2VeFuzJ%mNaT60jJm6r-_+K|hDwxp>N6x#VWq}| z5J$rUl_>53_5KGm?%uo2{o0ncch(3U?y2^AHr_Yi!52AAJ?~&<5TTMt&5XpsUu*IJ zD=SeQ|SSRia3eRwhfM5<=Ck@F$e{y2RtDGqFRI$vy3e)fwZ(Rw#tbBK(!e`_))_Gup79 zj^`9~GtNjZr-@28b@usw~M2(p`FbQIiH;+%CPN@H>I{ZU zEvf~NW=X2Z*!{UVrJN@BaEAAA`jb4BM?|)`*YC~DX$ME)K2d^AA_pPO1PTf<#d(E~ zc+YV}PM$fZ<3tzt4~DvP@2;FDT9&9qyEI9VJ1M~TZ@l)Fu3cT%jjKC);iapD@qdId z8tLK_Pw3{_UH$k+Z|K(b8;wz|>Y+xXJz1XU*7|)Fre=)S-?*aR{oQ}U-TXvd_T#1R zKd=Ap@Bg>*KWsJRI?Q?T^&h{gh=SW2?C7=2uj&S;b%U>Cx35u^=*>4T>-OzCGBYh5 zU&io%OrQSDr}QW%Z{Nolqt2Z>tKa^YeoLSDKFM6G}F2DL?z5418w7fX00)AOtTGSqLE*>*vN;N#eo(JPlHv}Il zj(n;{_SEPZGup%J6+G{2a&R5x*H0Vhc+b16c)1T@W(_?;2us#*&!V5 z^X`0>!PgulypQYUFB-PXauuGTm2Yjg7+24bL;c8+ijGk9udiZRN}n)$@z^EjA6Mc&PfddUsG za5M~sTW~@I4RyK0^l`kscs%cT`k2|+%(R?j;a1ak24d@g6eb2t#E zPoHiKOP;snW4d$DCYp2S+6~n>B|fhAnF170gyx0{u3{~NDm5qd0r$R_-*{aflAMgj z5&}8mzI%cvJ|^}4KBuqU@$oe~?rl8Z2(4;PcdmU3PL%#_+~Hsdj}$2E(IHQOzmM@Y z`0C#VU(*!4&D%8(ay2NyXAj=qH!-t@?&d3dAdEhqgX8Ty<#`7m#DzDpsG=-Y(Ra*x zBf{#A%>3LEq5U}&c1JV)1&qIGma;VKsKe>Ai-B&zvm2Xh`rh}ysaIZmNii+uba7f$ z$eYjU^RzHbPP@JBElqH?`|!vRFte8ZF60cQ|`327&v5%mBfyM+S=K6UWxk1$3COQ z<>Shr>#hkN%;){M9e(x$ixv7$f@3WEL762E$$b!5{pde(vW#p?mjl z=`z<0t#+cl{T;2WF6qUWexS?Oe#{(0b$T6Lc;r!Czjj;a&pxJ^&ay z3I_gFnVD+f8{he^uHC$?7LlAAZ@r}-eE+-p=l}X&)bW)ijp3VL`s`=WlWiUHS0#Vk za3|2y`OA63%cc+*Qz|t(JEJkQ@V5Tt&8@91*0L=({99ZHPjK#iom2U6edV^o+q?eq z!;Y7EyQ6u3pO#nXRo4R*x}ZjX1Z3X9%ygK7338@)KkP-I%=L)3b38mXGfE*Ba{N$U z$(tRPHpR==ZGo*+28g!gaj9`}yxgt2xx>2pj$m^QV$%?X7K?V?CpE`-A!?Q#NeCKO0%coFRCWj=lK;HzwP-qM&C7Il_u26`z4Hcr{HJ6-R0*c=hLUM=~eX}e>5nAJs z`$qfv_HhboLfV3gG168MVJaFJ9!!+be+EI%m}dj8{_d^2+9fg|Xi`9%1Y9zl23Jxe z?gGndtPq7_9jf$GIEFaGShN^FuPddjIh7RYZ)P$xg$Gc9I|Z^B2%DBF>BG_$@>CHh zcn>Ys%e~`xw6As=q*yA1Qj{uXo=QP=j0*5Y!HH4Co7ONuAN<(IG=nmQ8PuMi1CLal ztfPuk=NN=W+`o=hY630Xnb{#!JlftBmR?!Tf>=vw6GgV5;(BO!u)`WADkj_V2tfsI zuz(!@+Kj!Ys|>>%q+puD#G=N~R58dSBNg+>=*^Xd?{^Jd92iKVe_fj-)>MO}B|ZyS z)(v=nr;*pgX7G|Yi5&q9YFPzF2@7SQ&Fs>uH|?7-CnMY zJxf^cF3#|+>zB2C?7KSyb}x4besg$ULgGE0}b%#Y`o4{QImu z%etB)l)i<-k>hY2z{73Y*ElVAHuhAdrdB7{3opE^H(tA`kA379boSI4omoAhg=|?@ zE??K`@)>>p55AQ|XPXuPNr0j??ZJf9V&s3;$labOs0NHcrSfojtv* zHR#vr&g#n5+uGPFa6pdf#Mx82a0%nScuc3yUee{uZ)oH8ZS51)xqthXKL1buNPp*V z{YyHru);cm4hCcBa7(9FSE1cVCy$>{&Hmln87S{9;~3SdiQc5_=K^}M-W_TgS}e?U z;op+IHdR)r3Z?-}?RwZO(yx$_PGv+q1uNU$9%!aJ13w9Xnoj`#0^?r5+?C4_c)3I14w!>CDsU1V zw<&m;gVj=y9RlOHF?2999m=_yXPWmkQWw0Gd1~f0Gntu}$a&H0Bl73q<4=vRRW*Eh z2!{%&62a#N%&Zy1%v2s3vx0_W4hBOU?>d+wG9t#V&<7Ax4o2Q2`i*%l$W_|sVCz@2 zg(?*wQCNk(uT_!;nIdC2IPE6LvD3xpm6=RpR(`zCC%jMjpD1Q55JK1;L0-&E2ZZ&V zh#d^ZOqUH$Tv3fsR3lAL4i$)}(KH~$zdziA>F;a8l|V%u>I!02DhS&jV9W}vUk05=EOH?u|@l?*t0?STnEB zIfWQPGhQI|GBay%amQtZhaaG?$Q8g zlx2AX&KvIWJ7ANVKLRtvs5yhP>8cT8Aw+#n6f<*kYM~(fl;E(tyQ^vHxTPo+cb*1B z0~{wa$F-K{9atQnx`Ad94}L^wSF=e0Je_Ve`@!xswVrqI7f&6I=KbB9AEvceHRDg; zJ#7l#=f?zG*VgXq%3D`;gQ(ahcjn&5zwN%xNxeX6vWqCnmXb8c72zCv-k)Dp-kX=D z9XZZUzcEJB*YByn`;=Lpsz1|Hg#u2~L{5hog9;2?}B3kNc=ewPjJfA8MC zCdBLG>|yxaF>u^`E^p^!x7r=WBvs0Zd}(=A9il=@;I=S3r~U069F&pzt&aSQcU|}+ z!NB6n&nX~?%2C|rlq-gtX|1VSBpJ5dFnXE z`w`7A%qq&{e|a(o|IuJXbYY#bGMzYfTzw2SQ>dMHFeY8i^f5-re;*w7IDOu{ejO*X z&@sZ>C%L=N;FB91LY9d#oFT%sixc$Ym)_K9d#vqSThjhWPjg3};@EuT!ynSh%A(e} z5{Lnw4z>0Mdn#}m54ghcE6#6z`Kx;E#UJVSe&-))4+rA@^$pE;XLRF@%las5nGMo8 z^iM_mh1prX_S%m%k3;dNKJf_+pxcvN1Ki-s=CwCo(`~LZE?j(6cL>ve?Q7rSg#H88 z)YdDnzO1Vl{?}i9RnI>4qAYV4(N+C(zwy@;Lk5^$zj9e~b93rKt2@_k z0BAv%zUtzm7d03ax^d^0mKNuA@7^6w=bL)%m6!BOzxWv~%=OW^HT2A(VXid7H??Gj zu5uj)z1+DA@QWEVKmg2K)}%3~@o#_kd(G7kJycUS1@ZX)yU}so;e6(Xs6dxHc)5eO zaqztNrQ5@CdhFp8W})2_W^hEO<=t)<{wf}V&v6udTz~h2q~kFK$KmK)KDW2^rMaU~ zz(ZIz)m}hDuXpe^6*|iMSKbDoT4skd@%(#%uYU^fthWbp8XLV`;tV+W`0s2d>Z`Zs zVP1DQ4xNv>kr96vxE<~1IJzxy+#_;zwC?@#u$+G_cNy}iQ%q04T!2ZKz&)s;~pX9y>-kJkW1GqZ-cW{0Ww zor2k6nVBgd2vaaK9ewILsKT-}cdDg0%}N2KS+hO9hG`Td!9bQM<;i43_o~6i#~h>7 z9WTe<@k;31M_IVCT0q%4U0pf5;&vf-qsRdAjSZDljSG#qL-lrM)~v_t%ntKr=FOXO zGi&;JeRMiHz#vVYG)a=?gy?(fM$3)F1mVq5;46epM^!BgDW}GS@lYBlj!6hg*pCs> zfgMh-`D;KdV8lu zp65!^M2?p`5*zS}kCSDoJT)`;0vc46Dm?63JP#BSxp#ab>vLSBnm&h69ckdL*C2iWd`-TG?$~4rYm0ydqGGk8bu~YGT+1tG)SFvT%zDaD{Arrv zOl5Mqv|H_m_y=gewsuc=P)9E<hp3QdoWuv3aAIG(dtdhnD__6-mK=)VNZn2s zW3wbz(mq#zriH<3v94W>c6U?`Cc1O&j>fz2zyHc9`}HDs)jQ~m$^NcxzI8?2ZcDyD zr%#{c?!B#j925W2>&HBrP{>mGT(cQYc$zA?!Ic7^A7?%&gM zU-)DF!@u`mYn|vs9)s5J-_`Tq{kFdHhkvMZoYMd6|MI`k5U1k%FMLl5rXRfUg3dp3 zUjNc>|8MlEPkmbcKlH8QgzWBY>Yx6T|4EPy}@Knt>Uhgti zvbUc4yO}9ikUw7Lb>7y&%ftv?-sEecvx`GOZd}G)H#UHgoVGJF_C0wBS(Y_?JWXB3 zoG()3t4Wa`PtB~c2abb}H64EhF(-~QxYPzF?1{HI3a+OeXHUJa=gqt^?ce9)beqD_ zCf+}Uhz>pM?_*8jXuD~?hR|N`r^=cLixWP8XoZsanWH5Q8yMh*7>Vf>9C# zsv>MXnhbP+Aov(=bcQgW*GJm2-*~B}8o^_Q3R$X}1<4g=mMeJv;Reu-aZ5JQjYSXR z?@U)991}d&0w{}G#ia7#B|&)l`bNABwQ3AGlTZu8GqYll0U^m{c-xgCw2$&VaF21q z8myK;Mh6(m!UFY1f63Q3A;KX}W&zyBghUG*q6v=B2&brcz~CVSITZ9i#6RUd>fpd> zGgN}{E$P3_$?<^G!)&+jm}x?2%#Hp`x2rS9PwMWS`%;K1nVl*~mdGrT9H6%xqLeaU z41sb31dVGMAWsuV+73E0Idna+c*vP_2~IWh_(tq)@2f1Ri2k&EA}T^qej|c2R%!SAIo%`+J<$*EBQRSG$Xm z+}+SCFT9A;v99}9-_VWAugGQZ#QZT;d;8kqkn)4)Ues^?Gk;z=y!#*gd;cfRwP&~n zS!aFw`rdP2)9riLWNoJHCh9P5Ofz-3x+s+@w7X5@>-T=|543-OU#qiATDy5eul(?J z9h+a#KmPB3M@utp#Y`F%2k>dC+1OWW6jXA(_xyLC(>(X_+nd`sZuj&*|4+ZG?|<)m z`rI%6vi{@$=>M#jfA|t|cY&zJyiOip(Umt})uR{A>qq{71l-xec!N`WLI2s`{d@Xf z{^0Za5C7x;RL_0?2byWOm9iNti_1is?&!+38~WqVe^K{vu)6TeYOf7`vs@)^s!as) z>eW|u;>3z92F0AcMvx-;??3-NA~F{xcqv$vDT9xK3gnKP6V?=(_cndZD(cldb!~`G z{qX%k;o*B-i%tr>MfP0e9K4ts_EhrJ|KLvr9u;q%_c3a3M8@b7l*({a>e7%$`p~I* zU>v>O6e{@L%w%RN$U8I!p0Q$#nnD5}JNP?=s)DJck82Y_3(j^zb*S=xb;-}tF#~8} zl|;!9c^ae7CeUkyE%dzGhKg1e5;@4C~0>&C^ z#`2j<9D4{LAycAF0=^0BO97E*7%-wW?{!_v*X6XJ1NtREGh;@eP9zVZ)!?PWCmbg4 z{}Vi#$|RZ!JP-G@UEu^K&S3`kzD=eQg<&QY5X93QW#j@s8V*&`Z!(_f07qfq4g{N> zx?yKzM|g+OoFfeG4f1Bjx}q#Zg(NCN>6;n6bhxF* zk8ie*?I3Wi9}vptcF@Cz1u&1npmnnA;*|z zDr;*tc7J_So44+&-W#fh&O=VpL*^U95+%+>&02k41AjWOh8mDSU(+XouSXjE-P?u` z8(N1L6#=J_r!m4n5OG2Tr^3P8-PzJ{PBtBsWDm!oRMgDug2qLq`S~RbK!tIyFgmFs zeWOL6LKARwJ}AI1OH*}GVzaX|N@9jFY_We86lQ(U!B3VBk^TWl*ke#o=|&xTI&Kc) zsl0+s@psRPQ7Xm=h7dppUw;FX%b&wWo@>DSHJOG&hq>v`X`QDIN6Qc2!M7RLDIFOh zDq)}6oZMwmpbO5mW@hFzJHMcrxdqKHugIOI<>h1QbvjBD)7IvFZEf7s^=p@L?rv+F zldRJ#WvmGftua=fwHrEhJAL}($Xv~Q0R`VvnzdB1R_Ci0JmxZYV4R19_7$XV0BfiGpqS+j4oF znVC_WeQ9-btuVj;Zu=rv3t#%mSM{-veN4ap>wi`jqK=U45_>s#N}Kllg#6YGCb zKYZzB`SYLr$NyKoPCvvM2kJ4R2P6IAAAMe5`P$dD%9Y5+Kll-O6y~+pUePB${Ym{N z|M&k}GyY8+vkAK37S8#<`)~g1`rxxqXlwnR7T~+5pMDBQdjx%h%Ccq;D(w<+SpjDg zGz6~^4qy!(N(@fHHf``+= zB=4pWgIt~>X@cVrT<5T^R=dTX=gmH*Stg_0Y2`fPk-h`2$6c?x98SUYo#&laUgu*S zLR1LGd=7{6dK;&KcpP&xqfL-WO4CHa-vMtk1*)2L`a4zr+O+rn%}evx`*^(QuEtG;Uz|7=}3Lzq>ISf@c2srYG?DbS6nV7=+0 zsGoj92+drcngyBJ+n`83k&#vk8Zmys7`4QB{)3dn*XbK=W(`jM?&DD<59@oKgRdt= zxV+A&^fZG`&OGfTM{%XvBTRK?eO>#6U`Ht6G53}wPKQDw|C7FF&z#lmx2|jd-iG`x z6TL~lAQu`BL%UPZ07hZDeosp#oPyJ8N;j_q6?y7V(a-Vn)IH@I#n4Wq$-%a+U%!I# z*ymK*lVZy0f2acLv|ByjawQlVgI1H_MEzD@{dQlzzBUHm!;CaV0ZmXy1#~K!J`AJ~ zQdx+HbvO%-U!<(tpHtH5YV6LEs>W-t%pT(E_!e+!`2Y_01)rQfa{Rr13KdK+&Hsti z>mGpK^>|w^^7NhHrGXz&!o#j0Znx7@k_DDoH|wsT<<8EIhTPM(sO$D-mE}&9T0eR>^zdY+;F#6E_0Ec=cS=7w@qSCyr0;jM5?})4< zC@V9Q&mI`0jT7xO_r3JJcIVW2uh;7~^n2^_o4Wk!Yr69KWxep+_w>DQep_$7`i8b} zL?=Yh@YS@vJ&^N2$Wr-tH?|MP7^MmQgM5EFjJ3EluMXD_?yPKW?x?CtSt#|w4}Yiw zcOG!iI3q(MCVrrBS#H52bF*^|54a)r@>{pB>!lz5KzHxm zQjIh8*o8;cg2!CPEH2FBY)<6g^p6(Rf*yh%b2yfZi?f?XMg= zsf81(>Mt!Q>9!T}OmDvVCSz@BZf=fikZoN!cUGNNSFgYR7FRsSRlozi*$xr0eeLdV z>kUr&Tf3X`zp&VWAAbK2zNqKE`-0A$y`)ZiP7_X#Cr_Q%SHAEyu0k#&Q%N(&7ryWX zfs~%-UjEqeb6szIlZiTA`_=i9fxLxe(|%PZFtdnHv~iG zDuobq@!}IXsDJDrnC$qzM$H(=O!_>|=9sO|Xa+T*fda*6fPr(l&-p|La@n#0d!W4$cy4wV| zU7os(dYRjTX&)VVYS#a5FtdlWF|#AB-#;7Pzv|sJwR}ilV1|+MMfrlADRLO!^NzGH z&hw6RlM=2i#}n=lM{4R&P7x>}ZbXqJd5S7O+)zD0ItC|p!fR9@@ALUszF;52$9AY0 z!`~OUGxvI*-@(hw4vFDjX@M6zU5$?+9nR-@a?`3NHS<-9xeb`6o_7e6OadwegjpzW zu`W^~ACA-1!SS*ET|B;C$Imx5O;h>1*E#d-b4B2a(evKN+sw?&a3bu3Wl)YHOMqT~ zmRWqOoJNjQ4IcjSIT|nt z{n&uxWM;}?TF1@ny{+Tqv~;HQG{zJhq?^~S5<0!D3y++Ww?9SbzuTKpyVpZ;)-sD4 zVe~r~xpuoNp~Omzh)SnZ3B9UDFnY9iXTZlO5%vP5J{*h{=%2)-X=7$hnqUA$dDiV| z0a8xzL8I_@mwK+JCg%EKrN}5yHwA6#P?EC{EqJU4`)cQEo=qNN5M}9PI zrhOljdzs|87$v3wVRLu(La3A>A8x?>&f3!)C#J;>*qiI zN%=S6{mY8m8(W&`EUO$vwUb#bE*yh@I`Ctq(FpF~N@0G!%iNikmS#0K*B7h?c_tcj zJ+VVntA?-AG~%r9(5A0dLgwpR>zbLH(HmDT>&d4c(-P5}|M~ZRPdh{>UU~gBSi9DT zKKfzyazRf$^_0$>Iirn@HNAmTb@lR9{pzp%d3Aep`ZK@!t2%$-f|4xi+=VkbbMCY{ zGd*>BJ*}*s(CYEidi=4cR1~%P{W&czpU`WsUD5vTKuU1IMJ4xo$2bai;w6tL zZ?!Zbob75dVc#Z%v_r!*mBAE^(*OVv07*naRGeqN_SJ9c(MKO`@)I2L28jnhobSC& zgohlw*vlL|KZT?3O}?Jiyq$Xa+fb>3Zz|->bjG8(c9(G@h~&_m}b_N@18eTj0ee)ljA}&Oolc(XNIzQj&`OltwmI2yK*G(7~Y81X_$)bIE$) z3dJl_i_>Erf+id}rwngqV#%tzsoE&C9h6tiDLbXGa++Ey0|XW8+M4y?8!Bbuii`_I ziFTXj#S@sr%;@Ls1uIq$!Q0cM`Ot##>IS^O)6Qu~$ELpK#oHS)Qr82G{2zoJ)H^yD z?P#A6^~Rl>x_9#}WhR|DenQ7^Sm!ak86&jfSSogPpF8h2-+1}(PMrurlJw*Z7un;i z)71!vV1IldMS*95jIm~nRLMGWu9bX`HsP@y_+%Gm9YLQ8Sq;#13=M`vk`8bft8pPW z&Jj8pd!B?;)u>dh0zMgYN*sv`XO$R)$l?NYo50hX?D_WomiCK1wfng`?8WKRtLo+k zuk8y~XE%Z}#@*#w>R@Y6RZ+@fP~LChKrCpED}d4dL_cPox7TiRUwv6SgKdoG1P3N3 zlGjF#CTe9t2?Ms$8e9v-Fp;2Z!uzBc;#}=(?f#v{!2a&<{;vM$5C2$~E`30s{e@p7 z3YJ6TlL}Q!_wQ{gw7Od61iizlIL&4-@)a^UR2$;g5n`exGIUc8L}Yz_ZbrR4)$!RG z4R?2l9L$1?lw?uEs@C?T)D}ACH~#8xseSC2KJ;^+&?K+4Sq-#;OpFNUFFtZfZ{57B zr=R`+INZ>LYlp(5Eu5-Z?&{B6xTM{^eSPdxAJ@&_D<>aSJ&yVp|Aqgpu43FzoI9s&u2KK`U;j(`cmC~vRsOsDJ3HIDeD#L@@t3}; zvrj%FOLEO{SU9%4tpD)e|M&HcuY8I9EEPl0Z~o?=S5yjZ64mTeNCLc6ilwVfeHIP+ z*R(NJ$g_n6=d#xG-~W+TxN;cb7!>HZ0teg;k{cq2(P)HobqH*xYWUCfRf*FN>#1Zc z2XE^#EuOL}(F4UpaM^M>axlT$4YGprS;UJx$T8P4BvvrwMaTA;kuGo~H8DA=1j=k%*m189$Ff4p}fc1X8y@ zHFX7YC9Y|v5JFG@p9Ex*N{So?VwNh+GFcMk&U&6^%3|8AoefnT1GFvK!%9xqA^O*8 zJVw?%WtyM*2|u;Pg9gw5rl~caOq3uvQzHAuIXq~F?ij1Ulg`t?=9Kh-`Jz!>Dfl8E zKuyV*$bhG0n?;xzLY_-e3^*Q-6(8W{_3sApm>D=7 z&gyHZ*(`5o_VzrErFs&K@2vl z031hCh?0jfopGFrCYJF+g0xPFa>C~C?>Jfs!arK(+-@T{3%`F*sdZA!mj7ZJiq+pM?0UaiiCXDJMm!u{8 zJ5<5_eu;x6q69#XS*AR9&&pO60tpW-Zp0F{Ur>usnuL zdDekvD90$ZS{-#d{pLMMTC@!iA~xd`lUl)2nI+27mc|&uDby$tgZb;U1CK?Vhgwcw zIyb53j{*ZXJQX8j*Nh9_dD+hb({a3QlPA-pjPvgF;rx}Uu6!;^;0wP8+}_^N{k1i1 zZEfqujT@v}x=tu{nsa}+;R3i9fTq~z8CghW#$e0uMI)oH14H}a-4iZ!-rEVUFbAEnFWf?H~ zkLv@^eo*aBPkEj;#(HmmN6$U?JSXub-MW2OH?G{$)0dvrhd%HzRq(1G67F(ccH`E4 zjS;*75rIhfKJ9npV;fkpRQ75^y?$Hg&!5%u%8Hz}?wp=L#&X{O@H^kpZ~U#_Qs_kO z;=uKmdb+#zTde+{Rpt1TRgtliS1k6qH-(u(d9U3ufJ ztJ>Z=kn6B-edjA$TIuS!7r&#EXV2;jU-_2)&cE<45}CQ9k9_>&x_0Z9I{mKx)X)FC zRu`7^wXc6wIft#ZJBw3f`s^?M3S`Rl&;8}Us3#t~pl6?cQlI+7rxYK^K#D%CSzL+n zso@td*Mq0wYbYM0$)gVmvRTxX{`iZ3tVb@LhrYR77r7zwah&(2;P2CNPkmf7gY_!l zP)?ii;YFX@^>>Mb@A;$P~P`)N#ubDM-=*Z)B4rbQSI)o?&Y1VFLGBf#e_|tQTU}jC7nH?4!`RQw% zuD@m-2eQ*5Gq_-tqCUVwy%JC^xeT{6{LPF$!#gPXJXBNCPSi zztOiU%0^N9{7i*l4>aB%h)LweV91;UjJdBlq8!JY`wAFN<0L#u5>+U(h;djxc~WUi zRG?_@ym^h2=(pptY~DN>pQHY7~eY>Wz(coT-V)KbCwggJ%^nuJt)>|yM*wR++>&O;)Dr*B=mp&cBeF$#A`6k>UK)wfjc zJUA{XW7IKdh#UlPJK!p-gr+gg6w{nFc2t$MYUayQ9Dkgo8`p0^*GKfJPyQScqY+ml z2k=YE34ct4U_*g5ETAL4_N714tFOGou8h^2SMwfpGn;wOe}Z*+(_EI;-X5%i13t=-h?#+M)jG4}X{gglUnJ z_43jweei=H!r9<@x4Eu}L3)xZ3g zbo%TWee<~&^y~*dp}ai{9V%UTs%LCacGp7Q#d$X$g{ zCNYD*Dy|hb^-Zo8PM() z6O2maKD67-)#_!++KJi-T^b@|Ni_QxU4Y+57N7ZTf z6f9%>S|#zdG533JG~9rIwhDeY_z4}|#F)-f}=0`j%{{1qpV62+EcWYXZo@wP5dg(^Z|RZ(fe$-743l^E>3gIygEN=+@O zM7g;lEo`ES^;NM{6>DXj_I<((1sL-boPBMDQfO167;DDOl0+F{*M29_Og9DE>_eq; zG|>LWmP9x=C@Llybe>nKLgv74-LfXm?alS3>(L$(bvcmnZN^gt-IJj*vl@ZcCf> zgryeXDfWsvq$VE=gP($ev9A^=J^Z*Z9_9{z`ujs*_nDHP)UlsIhHGj%#Nu`CpoYeU-#ad7ewib9Z4*d#tfDGcQqI!M7uvoi1zc10C+h zQ`TCBN(nLh?$W$Q=$AB0b?V#&qF!y?8tiM46KcZ!%H^xCskB0)dQbN^?&$29vnq%p z?Q=z?%CyGGzc=60#it+B;~#!jvz=M>yM6fx~z6?eh2a+UwuZg>$LC^X1=B%eM6L555Ug43Xg-EiFY|IN8%|582A^YtlN< z@BGu>)sL@U(VzQ^|178ebuF-tPkicQT0MP2`|wVJ^Kks^0z8r_X_ZR4^IAFgxK5wG zpaEfYcZLS=){{>^p}&mLPeYW;LfT{fYg_8}SM;Gz{Jh+;+TPmGlaHQPJB><^zXHrc zNZ^AMJ(8=U3_n#~Ua~)_s>Vs>450(`+3t9x3R>uEHT;lj8Mb@04_)D#jnq-h5a>BwV7 zOPs+Z$rNKMvq)_~_N;+G`v`5^U{le28l1pqgahODsb-BqCU0wz{#6hgC##bNB6tOd zI#m`kC6*}iPtdA@fm6yR@RawTf|U%(8tEqH8kfc#%b)E8(ELYjM?94K4JyWI3}h1@8>u! z4#9u?tY8)tV^AQSAc$iKJ7H71gy7&~8Rd0=Nn{v7b?}v6XhG`RLgNPGxnuw*5tTF)=81fOg@%- z*N*FmaIV+;cm>Ww$$DEbyqCEXK=VdnyCX2c!EnW(Xqvc3LZ>`U5%N@Hl!epD%$Pqm z*Cg|ci<*Wpz5M}gZr;5k2S1%{+*{+MR_XOuQKHaqjnnDY76u6%9(nXJZ4){kVj$ee zI52dx9^Ws=uZ8pC`{H=|I`%MRF{Em>I*J(k61s@}nVXwayWN(z^S++y!Qb*$ zHIA2=nGy_>g`k7sK%0ciE7mZ-xWsyUN8RJ8Q%+bV&k}Xpxm?-Z#v$`Qsnc+7 zK{-*CPS%!3IbulF=1ON~W=<9oPzlgImGj2I-kv5Ht@`DS@}S^b$A9bY}B zEkffNbGwrBb$UADy37B%qW5osqlFB75-rcI64@H7p*3^(SV@v7hR9XGa~dG02k^?h zyX!i(dR&W3eeTz9Ykl)3dlI#^xh1EgM_XpP9liS6t3+SMn&GM;%RAs)bE+O{G~Cu# zzWV$6qtE}Ye(=>VYxDXQ{pj0Y*K^#xZ{519dsnXMfa{T$Ui`M+xcs87U3*2(fA9M^ zdmZRrVfb(9^!ej@>*f_%9(48QYuX*GD@i8W$6-5p;*=hJ_k6yT>zxg-+ zdA)k&s1vgQGj=E-@T8XlK$|IzNG*DfA~i_b?TH>S6As^?-;}R{k?#OO}lC8d(y}x zMyP;OnmlusG^lX&dUp7-CN+4`GD-j4+%IWAv}5&X+!hrw%@@+ZvY_fA_JxYzotIe>XGl`kqjOo44^w z$HnEqq3Qp?L2HjMcQMYFfZ=@S@p`m7XGS)%>HSVO$m0iI&Sh;@21%DMZkEMl-2 zFUjTQ-d-UpOEaZ_czm8Iv7A7@DV~loS{WPbO29YE%Ur2=-cx_~4f6h;I$@{a#F~QF zdEU$k7n-%1dES`){XfPAc&+!PlV{~)c?dUACBqY|*tn`3p>T&%lP^(1gGMkC1kLeu zz&0SLMFWmwfwSambH(WEC}7Myri@uD!sv&6YGo+DEN04Zpi=f>kCVTzH`bE>s+YI( zyIdcqLQ%K4ORiAXk+!YbIn5JtTsd`GZT7Ffu&6Rb_2y?)$3R|MUR`1AtikupnKSCq zr#CmJ`K4v{C*n-B!Ff!5DIeDz4HqyoldmI!Yn$kdDN4YAlW>x!YWKSMqZNINM);hD z0eU+4e$p6{S~I4P5uNcFlR~e8y6_z#&X~-AD`SORh0_y4K2$8^Ym?B6hk%lC9H-!O zI{1K|z86gEj;5ZU!h7}cvH~Zp@cDw|zt%b+guiJa(p_Is++sM-SjGE%^kIpF%njQv4&PMtcZx{4Tw0w~p`PoAbq zLewm$*dFh`|7l2MIMjYm??%(iRKkZbNwmVC&%0ApE8cWw)zbFt1o}$^V;57*ZTUJYK?UJ&NXdp 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⁡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

jOnPFcDcMq^ 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͆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 literal 0 HcmV?d00001 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/favicon-16x16.png b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..efcdaf8c4f3fdaa54d3d09a64b46d44c8b198acb GIT binary patch literal 985 zcmV;~119{5P)OzIZm#UwdXRG|VnjmNS#d|jKfyyduKV}9``z!?Z~ED1pU>yPXYcoWFVWLAq`rG- zQ0;kpNZSKyPuHOOe)o|2-kSsJiK9d6$EQZr54#7|o&Ib$Ty zT7xaPnU$te#M51><@#1pKFE+3i%0NWtY#4Xe z68gtMn!5mF;TF>Do6$E`5i#YmFk6VZ#!XsNt>#vVaBaoe(oA}88HOSk!V_NMhhM$? z_{##9{|Inu2UJ zUPANJ=wk{>NG2ZD5s5`eN9Mo45^;md_|!P(zdc8g4O}JVIE^_->Dd~ego-|^0h4(P zft7U(*f2>tiu9YXrLvHP6}|SR=mpui4$6vMOip>ZIXcSBpI(HTN|BRVrLwx7CtvF3 z;F)je?0AEU(i|40HNue%=H^!zyFSW-KY-ot!tTh%;w&J;>_j9Kg}NO(Q48v@H9m)| zGqM`a0Mdv?6Ufmd50-=2^K;m>tBrVECKi*BlQN2CE|MvU^^G{*xkXa4ip6R}&MRlk z??;hR6y$EArM{Wn+xAeDSAwQUG7$$=A*G-t(3~!hUc>HgCLT#>08{8Pt;i`szD}?^ z93$`Vf2p3o&(irX2@GE#d(4Bhc8^pdjEHDztHOydkl@DH4X$|xxHUO}L9ZjtYR1s? z1n#y2II8MMd#sYI+IlS2J03DHnjMG|S0$wdMI_gkxO;b+J98n-85Rr~Mv^8QHG8*X zDHUuKYI)u1=*rSD)i@r3G-V`ek974DEWQ!)10GCwE@2l2vbHv|xVns@OtF0b zHc`ooBu{A&eMpj|VNekruO<2P3o9?5aq)WJCiZt3XgwHYU#H-3yN`PJBzd-3o_c&v z+kfFS`Dv{6^G@e?yn0}Q{{a91|Nks(({2C&00v1!K~w_((kps!i}F)F00000NkvXX Hu0mjfSHj!7 literal 0 HcmV?d00001 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/favicon-32x32.png b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..56a96defa041a87587f37cac89d42d28c9b67b99 GIT binary patch literal 3328 zcmV+b4gd0qP)Q`Fb@Ad}H8Znct*Plz^*Xn@Vr7qNH1?~{ zJl(72=DE~)kN2q+qh0F6iAgzUN`B$OKGkaNSC=n$s3z0*vKbluLRVi(=ggU6?{w$) zd&QZ_z0x;_USrGZP3+meksU9;!rSX#;lRGlY+Cgy2mfOOudaK8KYz4=bv3W^*KaoS z%bHg?ug4SfDDzu=e8y~gG9TY2?oTRHUp%WPe@ znLi(SgB@Eov17x=)LC!6{%gKF{cEJ=+N-#C)KangeCqM+Jj?A<4V3JaKEZBaJE6tut=2ya~bWn1+?>;eM%fLL#|Kq87dM3SDf;JfLQEO>oZF<*#e ze-aWEkUk?tk0t5u1syba4{)h=BMpwdG<1Eyje$?7uiHY?5Tm7fV%HzIWH?2A_lKPQdM)+MJ82l#&AH?2x!U;wSDJq>P`{I=rk&il^)9!^IJkV_ zpJ_P%8?HF^akXPF*9SktwKRxyGbD4eHHTw2jr1p`(CMk9ZJ?6Nox}L(Y=lqhf_!+~ z$-c8;J~=bQo|At5^K_6swGn=QI>38%8vg5&kv(-1doRkI3QizC=4C9?w-dLnr`flF z^Py#&>{~#y=~<2iXYfVeGQPc4MuRWs`zi?fon*>G4E40rclj#LrY1__6616l=ysv{ zLj*l;hCDuGO@z>Z6OXHlNWe!R&_gijMlY+3o;;kq!VHSbO2{s@QMG(7m5!H5G%_Mn(_WLm9wqX7tpa1VG0 zMdQRo&4(f(;-YEkwIC;(N;HTjOC}W7a;N1kcWyLtxb8E)`QJmheFLD2@#7_nm^JNj z%I#HTXXOao*+tzMkP<=>s+L4di_hUf4Fn+uXX_fce%FUi$t6o)fW4rCv@|>Z2qZKL z!))1@6`8=G2X|K=-cW!mjdy8pZ|7*;2~Pc`p7##zWABmwX4=d#wB7II<4?cfvx8r8 z=*S@s{qc{qw%tXNBpIVImxQSlL%}!(ypUlkBp3+LdA}K-AoTS+arZj#J6#x~nT#AZ z1+%F{R6#Cgiyce$2#Rt_8DCOC^^AFh12IPAjNs`fpJMKmDm>jj+8;ciyQ71?huxwB zx z5b3UVd=Kx^<8$GO2WWLP3y*3=le$ZBsg3-S42)@E##fGE!lcQJ95sfV0y}0Aepzos zTnWMe(Td}-I2x@Uf7^HT)cy}M%IrkC+tJ1(DjylmiZ$C=y<NV68A z7}G@xL^HJ&l`wa~(>%F+6&b_r=%>_>w|X}PYjzVapN=me5zH(@35)b(*u+5^`ZW*~ z)e-fI_UrS|(%6g?3F8zvau0^+?sniCG*Xabqu=epKjar)s{*_NLkU%!s*=3Kq?o9J za3q4&l8(t}LX`~2wo&NJMsdGs(1c_7dV3idjNqtb!TF{3C zUnffpFCIyGSs4`-lTj3fWRydpFmVz0RS}WEgKShAOH@jmw0+Ari4fN_o&1!IW(#zs$ze*-ueDa|Uf4 zy@Vp7tTdu#0XgZGs*?0xT!_MeL>x=H5wF`zC?<2?A-wer5DJCR3T)^N28<>nHUUPr zA%|Yy5Vtz-5fY^vi^hmYqv%ECG7V<(ELL)&M$FMXvf*a3ttQbqgT#bm;ZP(Mv4n_( z1QKD5j!P}KslQfF`tWknZNmk?0)&S`!bU~Zdjb&-l0i(Djp7|pBSNSV2E;K4V{X;g zbMok6nr_^o-{qqqE0^n6ZgBqGHBQ%F=J?Umv^F*pcekU4-5BMd=rt-jorI**8Bi1x zEzUj~96gMmvl>~fWm!RFN1!)CM6}?z$eogIMyKe=wB}$jXED{B!R{;@vm387rMZPw z&R)ttc^}^!>nZu*Ek^8p7u$zBc~?^ll!;D*s2EwKB|?`d-^wSRX8Id%kyTYq z+N>F9Rz64Ji!WkYR)Z{BvF(|qjM=u8Q7^2bd-h@?S%oM9*m|Q8tt<+@fqn})d3JKLb1|5#NU{NE zzYkfwQ)9VbY0TqZx> zOLn*iU7rAD!bd^Ofj!(tMeliXGps~_faoH|yaMd@7Vb3fCpYs1uD$~V{U^D3<3rr; zuLK}JClWc#?OPww*>MPKW-~@bAzc_@F&NQlwMeq0AuiB1BuddAa1(0$lF+5S7+MdK z9%>~&e3d-sIdu1G8S1)&Rn`#?N0D?!DyKh*qVthvR+v7aol*7&WTf4sXt;;RXFnuA z*F$+p7jvt-8J2&K3{wXo(LIBaD9O}o&>+Rd3r18BdtBZ41jGgg`|{ytjWm-`0&4 z{K@P&FqN}cXLIfLSdu*;7z*MS*L^f59GUZ&!rEUAu>I{c-rQ;9pWd%z<-eA&;x~5I zZ7pKcn?=m~NsOt_2AK6ikhyClo)Tc+w!V)g)n}PE`2vflALogg=a^o4h8LDxVacOs zm@~1CnPW~-UHKhTN1vi<{Ar$9aF(_I_$Ssrdz7C&ca+6H>OsoP)KXe%`rA`bs7G(m zP*`XnJ4eQ5Q_$&ECQmk_*K5eKDwxf(2(LnJu1t2eLQal?!60L`N?2`LfeQnfSqd2z zEg2bF(oEm)%g@(QT59~;la%{E00030|FqVC7ytkO21!IgR09A+z2H4*gm$_B0000< KMNUMnLSTZ7hg3@d literal 0 HcmV?d00001 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/favicon.ico b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2769aab55b7e24562b3326dff10b8a581f441d9a GIT binary patch literal 15406 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%0D`&YCT2Xcq6z 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 { @@ -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 { diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/double-ratchet.test.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/double-ratchet.test.ts new file mode 100644 index 00000000..19df6bd0 --- /dev/null +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/double-ratchet.test.ts @@ -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() + }) +}) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/double-ratchet.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/double-ratchet.ts index adabebc0..986e5017 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/double-ratchet.ts +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/double-ratchet.ts @@ -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) } diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/index.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/index.ts index ba21d24e..c4b132fc 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/index.ts +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/index.ts @@ -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' diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/primitives.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/primitives.ts index 65a135c1..a490265d 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/primitives.ts +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/primitives.ts @@ -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 { diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/x3dh.test.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/x3dh.test.ts new file mode 100644 index 00000000..62a07290 --- /dev/null +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/x3dh.test.ts @@ -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/ + ) + }) +}) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/x3dh.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/x3dh.ts index 47b75947..0b811b79 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/x3dh.ts +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/x3dh.ts @@ -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 { 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 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/index.tsx b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/index.tsx index e977e95c..1ed2d290 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/index.tsx +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/index.tsx @@ -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( () => ( diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/api-client.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/api-client.ts new file mode 100644 index 00000000..f470b493 --- /dev/null +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/api-client.ts @@ -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 + signal?: AbortSignal +} + +interface ApiClientConfig { + baseUrl: string + defaultHeaders: Record +} + +const DEFAULT_CONFIG: ApiClientConfig = { + baseUrl: API_URL, + defaultHeaders: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, +} + +async function request( + endpoint: string, + options: RequestOptions = {} +): Promise { + 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 +} + +async function get(endpoint: string, signal?: AbortSignal): Promise { + return request(endpoint, { method: 'GET', signal }) +} + +async function post( + endpoint: string, + body?: unknown, + signal?: AbortSignal +): Promise { + return request(endpoint, { method: 'POST', body, signal }) +} + +async function put( + endpoint: string, + body?: unknown, + signal?: AbortSignal +): Promise { + return request(endpoint, { method: 'PUT', body, signal }) +} + +async function del(endpoint: string, signal?: AbortSignal): Promise { + return request(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 => get('/'), + + getHealth: (): Promise => get('/health'), + }, + + users: { + search: ( + data: UserSearchRequest, + signal?: AbortSignal + ): Promise => + post('/auth/users/search', data, signal), + }, + + auth: { + beginRegistration: ( + data: RegistrationBeginRequest, + signal?: AbortSignal + ): Promise => + post('/auth/register/begin', data, signal), + + completeRegistration: ( + data: RegistrationCompleteRequest, + signal?: AbortSignal + ): Promise => post('/auth/register/complete', data, signal), + + beginAuthentication: ( + data: AuthenticationBeginRequest, + signal?: AbortSignal + ): Promise => + post('/auth/authenticate/begin', data, signal), + + completeAuthentication: ( + data: AuthenticationCompleteRequest, + signal?: AbortSignal + ): Promise => post('/auth/authenticate/complete', data, signal), + + me: (signal?: AbortSignal): Promise => get('/auth/me', signal), + + logout: (signal?: AbortSignal): Promise => + post('/auth/logout', undefined, signal), + }, + + encryption: { + getPrekeyBundle: ( + userId: string, + signal?: AbortSignal + ): Promise => + get(`/encryption/prekey-bundle/${userId}`, signal), + + uploadKeys: ( + userId: string, + keys: UploadKeysRequest, + signal?: AbortSignal + ): Promise => + post(`/encryption/upload-keys/${userId}`, keys, signal), + }, + + rooms: { + list: (signal?: AbortSignal): Promise => + get('/rooms', signal), + + create: (data: CreateRoomRequest, signal?: AbortSignal): Promise => + post('/rooms', data, signal), + + get: (roomId: string, signal?: AbortSignal): Promise => + get(`/rooms/${encodeURIComponent(roomId)}`, signal), + + delete: (roomId: string, signal?: AbortSignal): Promise => + del(`/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 } diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/base64.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/base64.ts new file mode 100644 index 00000000..23782681 --- /dev/null +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/base64.ts @@ -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 +} diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/date.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/date.ts new file mode 100644 index 00000000..72e070a2 --- /dev/null +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/date.ts @@ -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 diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/index.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/index.ts new file mode 100644 index 00000000..98b49823 --- /dev/null +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/index.ts @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== +export * from './api-client' +export * from './base64' +export * from './date' +export * from './validators' diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/validators.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/validators.ts new file mode 100644 index 00000000..d3eed237 --- /dev/null +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/lib/validators.ts @@ -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, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +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 + } +} diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/pages/Chat.tsx b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/pages/Chat.tsx index 4bd3cd50..01ffe4d8 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/pages/Chat.tsx +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/pages/Chat.tsx @@ -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) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/services/auth.service.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/services/auth.service.ts index 5ebdfef6..76bd5740 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/services/auth.service.ts +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/services/auth.service.ts @@ -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 { return user } -export function logout(): void { +export async function rehydrateSession(): Promise { + 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 { + 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, diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/services/room.service.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/services/room.service.ts index 5365fa06..5d63b13a 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/services/room.service.ts +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/services/room.service.ts @@ -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 { +export async function loadRooms(): Promise { 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 { } export async function createRoom( - creatorId: string, participantId: string, roomType: 'direct' | 'group' | 'ephemeral' = 'direct' ): Promise { 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) } diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/stores/auth.store.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/stores/auth.store.ts index 4884050a..f1fc3373 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/stores/auth.store.ts +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/stores/auth.store.ts @@ -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' diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/stores/index.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/stores/index.ts index 8fe98628..58ea8819 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/stores/index.ts +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/stores/index.ts @@ -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' diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/stores/session.store.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/stores/session.store.ts deleted file mode 100644 index b184fe65..00000000 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/stores/session.store.ts +++ /dev/null @@ -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( - 'chat:session:', - DEFAULT_TOKENS, - { - encode: JSON.stringify, - decode: JSON.parse, - } -) - -export const $lastActivity = persistentAtom('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 -} diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/websocket/message-handlers.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/websocket/message-handlers.ts index 9d8bf45b..ebc1a279 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/websocket/message-handlers.ts +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/websocket/message-handlers.ts @@ -94,11 +94,19 @@ const errorMessageHandler: MessageHandler = (message) => { } const roomCreatedHandler: MessageHandler = (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, diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/websocket/websocket-manager.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/websocket/websocket-manager.ts index 87b12da5..1ff6c535 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/websocket/websocket-manager.ts +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/websocket/websocket-manager.ts @@ -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) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/vite.config.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/vite.config.ts index 2ae4fcc2..38fbb961 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/vite.config.ts +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/vite.config.ts @@ -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'], + }, }) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/justfile b/PROJECTS/advanced/encrypted-p2p-chat/justfile index 375dc13b..55d89e75 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/justfile +++ b/PROJECTS/advanced/encrypted-p2p-chat/justfile @@ -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 From 5adb079e766d489af34cf29d9b5fac1a94749053 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 29 Apr 2026 02:57:17 -0400 Subject: [PATCH 2/2] docs(encrypted-p2p-chat): refresh learn/ for the audit-fixed architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update all five learn documents to match the post-audit codebase: - Replace references to the deleted server-side X3DH/Double Ratchet (`backend/app/core/encryption/`) with their client-side TypeScript counterparts (`frontend/src/crypto/{x3dh,double-ratchet}.ts`). - Drop mentions of the removed `RatchetState` and `SkippedMessageKey` PostgreSQL models, the server-generated key paths, and the deprecated `send_encrypted_message` server-encryption method. Add an explicit "why we deleted server-side crypto" rationale in 02-ARCHITECTURE. - Update API endpoint listings: drop `/encryption/initialize-keys`, `/encryption/rotate-signed-prekey`, `/encryption/opk-count`; add `/auth/me`, `/auth/logout`. Note that all non-auth routes require the session cookie. - Reflect the new schema: `IdentityKey`, `SignedPrekey`, and `OneTimePrekey` now hold only public material; `User` carries a 64-byte `webauthn_user_handle`. - Replace the `/ws?user_id=...` example with cookie-authenticated WebSocket usage. - Remove all line-number references throughout (per the no-line-numbers feedback) — ~300 stripped via regex pass plus targeted prose rewrites. - Update WebAuthn copy to reflect `UserVerificationRequirement.REQUIRED`, the per-credential-backup-eligibility flag, and challenge-bytes-keyed authentication storage. - Refresh the file index and project tree to match the trimmed module layout. --- .../encrypted-p2p-chat/learn/00-OVERVIEW.md | 60 +- .../encrypted-p2p-chat/learn/01-CONCEPTS.md | 727 ++++---- .../learn/02-ARCHITECTURE.md | 1545 ++++++++--------- .../learn/03-IMPLEMENTATION.md | 1078 ++++++------ .../encrypted-p2p-chat/learn/04-CHALLENGES.md | 572 +++--- 5 files changed, 1954 insertions(+), 2028 deletions(-) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/learn/00-OVERVIEW.md b/PROJECTS/advanced/encrypted-p2p-chat/learn/00-OVERVIEW.md index d416b275..fb7315f8 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/learn/00-OVERVIEW.md +++ b/PROJECTS/advanced/encrypted-p2p-chat/learn/00-OVERVIEW.md @@ -20,19 +20,19 @@ The Signal Protocol is now the gold standard for encrypted messaging. Signal (th ### Cryptographic Protocols -- **X3DH key exchange for asynchronous session establishment.** The fundamental problem with Diffie-Hellman key exchange is that both parties need to be online at the same time. If Alice wants to send a message to Bob, but Bob is offline, classic DH cannot work. X3DH (Extended Triple Diffie-Hellman) solves this by having Bob pre-publish a bundle of public keys to the server: a long-term identity key, a medium-term signed prekey, and a set of single-use one-time prekeys. When Alice wants to start a conversation, she downloads Bob's bundle and performs three (or four, if a one-time prekey is available) DH computations to derive a shared secret. Bob can complete his half of the exchange when he comes back online. This project implements X3DH in `backend/app/core/encryption/x3dh_manager.py` on the server side and `frontend/src/crypto/x3dh.ts` on the client side, using X25519 for Diffie-Hellman and Ed25519 for prekey signatures. +- **X3DH key exchange for asynchronous session establishment.** The fundamental problem with Diffie-Hellman key exchange is that both parties need to be online at the same time. If Alice wants to send a message to Bob, but Bob is offline, classic DH cannot work. X3DH (Extended Triple Diffie-Hellman) solves this by having Bob pre-publish a bundle of public keys to the server: a long-term identity key, a medium-term signed prekey, and a set of single-use one-time prekeys. When Alice wants to start a conversation, she downloads Bob's bundle and performs three (or four, if a one-time prekey is available) DH computations to derive a shared secret. Bob can complete his half of the exchange when he comes back online. **All X3DH math runs in the browser** in `frontend/src/crypto/x3dh.ts` (`initiateX3DH` and `receiveX3DH`); the backend only stores and serves the public bundle in `backend/app/services/prekey_service.py`. X25519 is used for Diffie-Hellman, Ed25519 for prekey signatures. -- **Double Ratchet algorithm for forward secrecy and post-compromise security.** Once X3DH establishes an initial shared secret, the Double Ratchet takes over for ongoing message encryption. It combines two ratcheting mechanisms: a symmetric-key ratchet (using HMAC-based key derivation) that advances with every message, and a Diffie-Hellman ratchet that advances every time the conversation "turn" changes. The symmetric ratchet provides forward secrecy: if an attacker steals your current keys, they cannot decrypt past messages because each message key is derived from and then discarded after the previous chain key. The DH ratchet provides post-compromise security (also called "future secrecy" or "self-healing"): even if an attacker compromises your current state completely, once you send a new message with a fresh DH key pair, the attacker loses access because they do not know the new private key. The implementation lives in `backend/app/core/encryption/double_ratchet.py` and `frontend/src/crypto/double-ratchet.ts`. +- **Double Ratchet algorithm for forward secrecy and post-compromise security.** Once X3DH establishes an initial shared secret, the Double Ratchet takes over for ongoing message encryption. It combines two ratcheting mechanisms: a symmetric-key ratchet (HMAC-based) that advances with every message, and a Diffie-Hellman ratchet that advances every time the conversation "turn" changes. The symmetric ratchet provides forward secrecy: if an attacker steals your current keys, they cannot decrypt past messages because each message key is derived from and then discarded after the previous chain key. The DH ratchet provides post-compromise security (also called "future secrecy" or "self-healing"): even if an attacker compromises your current state completely, once you send a new message with a fresh DH key pair, the attacker loses access because they do not know the new private key. **The Double Ratchet runs entirely in the browser** in `frontend/src/crypto/double-ratchet.ts` (`encryptMessage` / `decryptMessage`); the backend only relays the resulting ciphertext. -- **AES-256-GCM authenticated encryption.** Each message is encrypted using AES-256-GCM (Galois/Counter Mode), which provides both confidentiality and integrity. GCM is an AEAD (Authenticated Encryption with Associated Data) cipher, meaning it produces a ciphertext and an authentication tag. If anyone tampers with the ciphertext, the tag verification fails and decryption is rejected. The "associated data" in this project is the concatenation of both users' identity public keys, binding each encrypted message to the specific conversation participants. The implementation uses 12-byte random nonces and 32-byte keys derived from the Double Ratchet chain. You will see this in the `_encrypt_with_message_key` and `_decrypt_with_message_key` methods of the `DoubleRatchet` class, and in the `aesGcmEncrypt`/`aesGcmDecrypt` functions in `frontend/src/crypto/primitives.ts`. +- **AES-256-GCM authenticated encryption.** Each message is encrypted using AES-256-GCM (Galois/Counter Mode), which provides both confidentiality and integrity. GCM is an AEAD (Authenticated Encryption with Associated Data) cipher, meaning it produces a ciphertext and an authentication tag. If anyone tampers with the ciphertext, the tag verification fails and decryption is rejected. The "associated data" in this project is the serialized message header (DH public key, message number, previous chain length), binding each ciphertext to the ratchet state that produced it. The implementation uses 12-byte random nonces and 32-byte keys derived from the Double Ratchet chain. You'll see it in `aesGcmEncrypt` / `aesGcmDecrypt` in `frontend/src/crypto/primitives.ts`, called from inside `encryptMessage` / `decryptMessage`. -- **HKDF-SHA256 key derivation.** Raw Diffie-Hellman outputs are not suitable for direct use as encryption keys. They have non-uniform distribution and may contain structural biases. HKDF (HMAC-based Key Derivation Function) extracts entropy from DH outputs and expands it into keys of the required length. This project uses HKDF-SHA256 throughout: in X3DH to derive the initial shared secret from concatenated DH outputs, and in the Double Ratchet to derive new root keys and chain keys from each DH ratchet step. The X3DH derivation uses a salt of all-zero bytes and an info string of `b'X3DH'`, prepending a block of `0xFF` bytes as specified by the Signal Protocol. You will see this in both the Python `cryptography` library's HKDF and the browser's WebCrypto `deriveBits` API with HKDF. +- **HKDF-SHA256 key derivation.** Raw Diffie-Hellman outputs are not suitable for direct use as encryption keys. They have non-uniform distribution and may contain structural biases. HKDF (HMAC-based Key Derivation Function) extracts entropy from DH outputs and expands it into keys of the required length. This project uses HKDF-SHA256 throughout: in X3DH to derive the initial shared secret from concatenated DH outputs (with the spec-mandated `0xFF * 32` F prefix and `b'X3DH'` info), and in the Double Ratchet's KDF_RK (root-key derivation) which uses the previous root key as HKDF salt and the new DH output as input keying material. The chain-key step (KDF_CK) uses HMAC-SHA256 with single-byte tags `0x01` (message key) and `0x02` (next chain key) per the Signal spec. -- **Ed25519 digital signatures and X25519 Diffie-Hellman.** The project uses two related but distinct elliptic curve operations on Curve25519. X25519 is used for Diffie-Hellman key exchange: two parties each generate a key pair, exchange public keys, and compute a shared secret. Ed25519 is used for digital signatures: the identity key owner signs their signed prekey so that other users can verify it was genuinely published by that identity, not substituted by a malicious server. The distinction matters because the mathematical operations are different (X25519 operates on the Montgomery form of the curve, Ed25519 on the Edwards form), but they share the same underlying curve. This project maintains separate X25519 and Ed25519 key pairs for each user identity, as you can see in the `generate_identity_keypair_x25519` and `generate_identity_keypair_ed25519` methods. +- **Ed25519 digital signatures and X25519 Diffie-Hellman.** The project uses two related but distinct elliptic curve operations on Curve25519. X25519 is used for Diffie-Hellman key exchange: two parties each generate a key pair, exchange public keys, and compute a shared secret. Ed25519 is used for digital signatures: the identity key owner signs their signed prekey so that other users can verify it was genuinely published by that identity, not substituted by a malicious server. The mathematical operations are different (X25519 operates on the Montgomery form of the curve, Ed25519 on the Edwards form), but they share the same underlying curve. Each user generates separate X25519 and Ed25519 identity key pairs in `generateIdentityKeyPair` (`frontend/src/crypto/x3dh.ts`); only the public halves ever leave the device. ### Authentication and Identity -- **WebAuthn/FIDO2 passkey registration and authentication.** WebAuthn is a W3C standard that allows users to authenticate using public key cryptography instead of passwords. During registration, the browser creates a new key pair on a hardware or platform authenticator (a YubiKey, a fingerprint reader, a phone's secure enclave). The public key is sent to the server. The private key never leaves the authenticator. During login, the server sends a random challenge, the authenticator signs it with the private key, and the server verifies the signature against the stored public key. No shared secret ever crosses the network. The implementation uses the `py_webauthn` library on the server side (`backend/app/core/passkey/passkey_manager.py`) and the browser's native `navigator.credentials` API on the client. Registration options include resident key requirement (enabling usernameless login) and user verification preference. +- **WebAuthn/FIDO2 passkey registration and authentication.** WebAuthn is a W3C standard that allows users to authenticate using public key cryptography instead of passwords. During registration, the browser creates a new key pair on a hardware or platform authenticator (a YubiKey, a fingerprint reader, a phone's secure enclave). The public key is sent to the server. The private key never leaves the authenticator. During login, the server sends a random challenge, the authenticator signs it with the private key, and the server verifies the signature against the stored public key. No shared secret ever crosses the network. The implementation uses the `py_webauthn` library on the server (`backend/app/core/passkey/passkey_manager.py`) and the browser's native `navigator.credentials` API on the client. Registration options require resident keys (enabling usernameless login) and **require** user verification (PIN/biometric), since this is an E2EE chat app and unattended-device access shouldn't unlock the conversation. The WebAuthn `user.id` is a 64-byte random `webauthn_user_handle` per spec, stored on the User row and never exposed to clients. - **Public key cryptography for identity verification.** Each user in the system has two types of public keys: WebAuthn credentials (for authentication with the server) and Signal Protocol identity keys (for end-to-end encryption with other users). The WebAuthn credential proves to the server that you are who you claim to be. The Signal Protocol identity key lets other users verify your encryption keys. These are separate key pairs serving separate purposes. The identity key is an Ed25519 key pair that signs the user's medium-term signed prekeys, letting recipients verify that the prekey bundle they downloaded was genuinely published by that user and not substituted by a compromised server. @@ -40,21 +40,21 @@ The Signal Protocol is now the gold standard for encrypted messaging. Signal (th ### Fullstack Architecture -- **FastAPI async backend with SQLModel ORM.** The backend is built with FastAPI, using its native async support with `asyncio` and `async/await` throughout. Database models use SQLModel, which merges SQLAlchemy's ORM capabilities with Pydantic's data validation. The app factory pattern in `factory.py` creates the FastAPI instance with a lifespan context manager that handles startup (initializing PostgreSQL, connecting to Redis, connecting to SurrealDB) and shutdown (disconnecting cleanly). API routes are organized into four routers: `auth` (WebAuthn registration and login), `encryption` (prekey bundle management), `rooms` (chat room CRUD), and `websocket` (real-time message transport). The backend uses `ORJSONResponse` as the default response class for faster JSON serialization. +- **FastAPI async backend with SQLModel ORM.** The backend is built with FastAPI, using its native async support with `asyncio` and `async/await` throughout. Database models use SQLModel, which merges SQLAlchemy's ORM capabilities with Pydantic's data validation. The app factory pattern in `factory.py` creates the FastAPI instance with a lifespan context manager that handles startup (initializing PostgreSQL, connecting to Redis, connecting to SurrealDB) and shutdown (disconnecting cleanly). API routes are organized into four routers: `auth` (WebAuthn registration / login / `/auth/me` / `/auth/logout`), `encryption` (public prekey bundle upload and lookup), `rooms` (chat room CRUD with membership-gated access), and `websocket` (real-time message transport, authenticated via the same session cookie). All non-auth endpoints depend on `current_user` from `app/core/dependencies.py`, which resolves the session cookie against Redis. Rate limits are applied via `slowapi`. The backend uses `ORJSONResponse` as the default response class for faster JSON serialization. - **SolidJS reactive frontend with TypeScript.** The frontend uses SolidJS, a reactive UI framework with fine-grained reactivity (no virtual DOM). State management uses Nanostores with the `@nanostores/solid` binding and `@nanostores/persistent` for localStorage persistence. Routing uses `@solidjs/router`. Server state management uses `@tanstack/solid-query` for data fetching, caching, and synchronization. The component structure is organized into domain folders: `Auth` (AuthCard, AuthForm, PasskeyButton), `Chat` (ChatInput, ChatHeader, ConversationList, MessageBubble, MessageList, EncryptionBadge, OnlineStatus, TypingIndicator, UserSearch), `Layout` (AppShell, Header, Sidebar, ProtectedRoute), and `UI` (Avatar, Badge, Button, Dropdown, Input, Modal, Spinner, Toast, Tooltip). Styling uses Tailwind CSS v4 via the Vite plugin. - **WebSocket real-time messaging.** Messages are delivered in real time over WebSocket connections. The backend's `websocket_manager.py` maintains a connection pool and handles broadcasting. The frontend's `websocket/websocket-manager.ts` manages connection lifecycle, reconnection, and heartbeats. Message types include encrypted messages, typing indicators, presence updates, read receipts, and error notifications. The WebSocket protocol carries ciphertext between clients; the server routes messages without decrypting them. Heartbeat interval is configurable (default 30 seconds), and each user can have up to 5 concurrent connections (for multiple devices or tabs). -- **Multi-database architecture: PostgreSQL, SurrealDB, Redis.** The project uses three databases, each chosen for a specific access pattern. PostgreSQL (via `asyncpg` and SQLModel) stores authentication data, user profiles, WebAuthn credentials, and encryption key metadata (identity keys, signed prekeys, one-time prekeys, ratchet state). This data is relational, requires ACID transactions, and benefits from SQL's querying power. SurrealDB stores real-time chat data: messages, room membership, and presence information. Its live query feature enables real-time push notifications without polling. Redis stores ephemeral state: WebAuthn challenge tokens (with TTL-based expiration), rate limiting counters, and session caches. Each database has its own manager class (`Base.py` for PostgreSQL, `surreal_manager.py` for SurrealDB, `redis_manager.py` for Redis) handling connection lifecycle and operations. +- **Multi-database architecture: PostgreSQL, SurrealDB, Redis.** The project uses three databases, each chosen for a specific access pattern. PostgreSQL (via `asyncpg` and SQLModel) stores authentication data, user profiles, WebAuthn credentials, and **public** prekey material (identity public keys, signed prekey publics + signatures, unused one-time prekey publics). No private key ever lives server-side. SurrealDB stores real-time chat data: messages (ciphertext only), room membership, and presence. Its live query feature enables real-time push notifications without polling. Redis stores ephemeral state: WebAuthn registration context (challenge + user handle + display name), authentication challenges (keyed by challenge bytes for replay-safety), session tokens (`session: -> user_id` with 24h TTL), and `slowapi` rate-limit counters. Each database has its own manager class (`Base.py` for PostgreSQL, `surreal_manager.py` for SurrealDB, `redis_manager.py` for Redis). - **Docker containerization with Nginx reverse proxy.** The entire application runs in Docker containers orchestrated by Docker Compose. The development compose file (`dev.compose.yml`) defines six services: PostgreSQL 16 Alpine, SurrealDB (latest), Redis 8 Alpine, the FastAPI backend, the Vite dev server for the frontend, and Nginx Alpine as a reverse proxy. Each database has health checks to ensure the backend only starts after its dependencies are ready. The Nginx configuration routes HTTP traffic and proxies WebSocket connections. There are separate Dockerfiles for development (with hot-reload via volume mounts) and production (with optimized builds) in `conf/docker/dev/` and `conf/docker/prod/`. ### Security Engineering -- **Zero-knowledge server design.** The server in this application is designed so that it cannot read messages even if it wanted to. Private keys are generated on client devices and stored in the browser's IndexedDB. The server stores only public keys and encrypted message blobs. When Alice sends a message to Bob, the client-side crypto layer encrypts the plaintext using the Double Ratchet, sends the ciphertext over the WebSocket, and the server forwards it to Bob. At no point does the server possess any decryption key. The associated data binding (concatenation of both users' identity public keys) ensures that even if the server tried to relay a message to the wrong recipient, decryption would fail because the associated data would not match. This is a meaningful defense against a compromised server attempting message misdirection. +- **Zero-knowledge server design.** The server in this application is designed so that it cannot read messages even if it wanted to. Private keys are generated on client devices (`generateIdentityKeyPair` in `frontend/src/crypto/x3dh.ts`) and stored in the browser's IndexedDB (`frontend/src/crypto/key-store.ts`). The server stores only public keys (`backend/app/services/prekey_service.py:store_client_keys` writes only the public halves; the schema columns for private keys were dropped) and ciphertext blobs in SurrealDB. When Alice sends a message to Bob, `crypto-service.ts:encrypt` runs the Double Ratchet locally, hands the ciphertext to the WebSocket, and the server's `message_service.store_encrypted_message` does a pure pass-through write — there is no `decrypt` code path on the backend. The header serialization that becomes the AES-GCM associated data binds each ciphertext to the ratchet state that produced it, so a compromised server cannot redirect ciphertext between sessions without breaking authentication. -- **Key rotation and lifecycle management.** Cryptographic keys in this system have defined lifecycles. One-time prekeys are single-use: once consumed during an X3DH exchange, they are deleted from the server. The system maintains a pool of 100 one-time prekeys and replenishes them when the count drops. Signed prekeys rotate every 48 hours, with a 7-day retention period for old keys to handle in-flight messages. Identity keys are long-term but can be rotated (configurable, default 90 days). Ratchet state is per-conversation and advances with every message. Skipped message keys (for out-of-order delivery) are cached up to a configurable limit (default 2000) with the oldest keys evicted first. These lifecycle parameters are centralized in `config.py` as named constants. +- **Key rotation and lifecycle management.** Cryptographic keys in this system have defined lifecycles, all driven by the client. One-time prekeys are single-use: when a sender fetches Bob's bundle, the chosen OPK is marked `is_used=True` server-side (so it can't be handed out twice), but the private OPK lives only in Bob's IndexedDB. The client maintains a pool of 100 OPKs and replenishes (and re-uploads the new public OPKs) when the unused count drops below half (`crypto-service.ts:replenishOneTimePreKeys`). Signed prekeys rotate every 48 hours; the client generates a new SPK locally and re-runs `uploadKeys` to publish the new public + signature. Identity keys are long-term and intentionally don't rotate — they're the trust anchor that signs the SPK. Ratchet state is per-conversation, advances with every message, and is serialized into the IndexedDB ratchet store after every encrypt/decrypt. Skipped message keys (for out-of-order delivery) live in the in-memory ratchet state, capped at `MAX_SKIP_MESSAGE_KEYS`. - **Threat modeling for messaging applications.** Building this project exposes you to the specific threat model that the Signal Protocol is designed to address. The primary threats include: a compromised server attempting to read or modify messages (mitigated by E2E encryption and associated data binding), an attacker who steals a user's current session keys (mitigated by forward secrecy from the symmetric ratchet), an attacker who fully compromises a device and then loses access (mitigated by post-compromise security from the DH ratchet), a man-in-the-middle attempting to substitute public keys (mitigated by identity key signing and optional safety number verification), and an attacker who intercepts one-time prekeys (mitigated by the three base DH computations that provide security even without the one-time prekey). Understanding which threats each mechanism addresses is the core of cryptographic protocol design. @@ -158,34 +158,30 @@ encrypted-p2p-chat/ | | | +-- websocket.py # WebSocket connection and message routing | | | | | |-- core/ -| | | |-- encryption/ -| | | | |-- x3dh_manager.py # X3DH protocol: key generation, exchange, verification -| | | | +-- double_ratchet.py # Double Ratchet: symmetric + DH ratchet, encrypt/decrypt | | | |-- passkey/ | | | | +-- passkey_manager.py # WebAuthn credential registration and verification +| | | |-- dependencies.py # current_user, issue_session, revoke_session | | | |-- websocket_manager.py # Connection pool, broadcasting, heartbeat management | | | |-- surreal_manager.py # SurrealDB client: connect, query, live subscriptions -| | | |-- redis_manager.py # Redis client: challenges, rate limits, ephemeral cache +| | | |-- redis_manager.py # Redis client: sessions, challenges, ephemeral cache | | | |-- enums.py # Shared enumerations (message types, status codes) | | | |-- exceptions.py # Domain-specific exception classes | | | +-- exception_handlers.py # FastAPI exception handler registration | | | | | |-- models/ # SQLModel database models (PostgreSQL) | | | |-- Base.py # SQLAlchemy engine, session factory, init_db -| | | |-- User.py # User account with username and display name -| | | |-- Credential.py # WebAuthn credential storage (public key, counter) -| | | |-- IdentityKey.py # Long-term X25519 + Ed25519 identity key pairs -| | | |-- SignedPrekey.py # Medium-term signed prekeys (rotate every 48h) -| | | |-- OneTimePrekey.py # Single-use prekeys for X3DH (pool of 100) -| | | |-- RatchetState.py # Per-conversation Double Ratchet state blob -| | | +-- SkippedMessageKey.py # Cached keys for out-of-order message delivery +| | | |-- User.py # User account + 64-byte WebAuthn user_handle +| | | |-- Credential.py # WebAuthn credential storage (public key, counter, BE/BS flags) +| | | |-- IdentityKey.py # Long-term X25519 + Ed25519 identity public keys (no privates) +| | | |-- SignedPrekey.py # Client-uploaded signed prekey publics + signatures +| | | +-- OneTimePrekey.py # Single-use prekey publics (consumed via is_used flag) | | | | | |-- services/ # Business logic layer -| | | |-- auth_service.py # Registration and authentication orchestration -| | | |-- prekey_service.py # Key bundle management and rotation scheduling -| | | |-- message_service.py # Encryption/decryption coordination +| | | |-- auth_service.py # Registration / authentication / search (no key gen) +| | | |-- prekey_service.py # Public-only prekey bundle storage + lookup +| | | |-- message_service.py # Pass-through ciphertext writer (server never decrypts) | | | |-- presence_service.py # Online/offline status tracking via SurrealDB -| | | +-- websocket_service.py # WebSocket message routing and type dispatch +| | | +-- websocket_service.py # WebSocket message routing + membership check + per-user rate cap | | | | | |-- schemas/ # Pydantic request/response models | | | |-- auth.py # Registration/login options and verification schemas @@ -199,11 +195,10 @@ encrypted-p2p-chat/ | | +-- main.py # Uvicorn entry point | | | |-- tests/ # pytest test suite -| | |-- conftest.py # Shared fixtures (async engine, test client) -| | |-- test_auth_service.py # Authentication flow tests -| | |-- test_encryption.py # Double Ratchet encrypt/decrypt round-trip tests -| | |-- test_message_service.py # Message service integration tests -| | +-- test_x3dh.py # X3DH key exchange protocol tests +| | |-- conftest.py # Shared fixtures (in-memory async SQLite, test users) +| | |-- test_auth_service.py # User CRUD and lookup +| | |-- test_prekey_service.py # Bundle/upload regression tests (incl. broken-filter regression) +| | +-- test_session_auth.py # Protected endpoints return 401 without session cookie | | | |-- alembic/ # Database migration scripts | | +-- env.py # Alembic environment configuration @@ -267,8 +262,7 @@ encrypted-p2p-chat/ | | | +-- room.service.ts # Room and message API calls | | | | | |-- stores/ # Nanostores state management -| | | |-- auth.store.ts # Authentication state (user, token) -| | | |-- session.store.ts # Session lifecycle management +| | | |-- auth.store.ts # Current user + persistent userId hint | | | |-- rooms.store.ts # Room list and active room state | | | |-- messages.store.ts # Message history per conversation | | | |-- presence.store.ts # User online status tracking @@ -408,7 +402,7 @@ Check that ports 5432, 8001, 6379, 8000, 5173, and 80 are not already in use. No WebAuthn requires either `localhost` or a valid HTTPS origin. The default configuration uses `RP_ID=localhost` and `RP_ORIGIN=http://localhost` in `.env.example`. Make sure your `.env` file has these values set correctly. If you are accessing the frontend at `http://localhost:5173`, then `RP_ORIGIN` must match that exact origin (scheme + host + port). If you are testing from a LAN IP address or a non-localhost hostname, you will need a TLS certificate because WebAuthn will refuse to create credentials on non-secure origins other than localhost. **"No identity key" error when starting a chat:** -After WebAuthn registration, the client must generate and upload encryption keys (identity key pair, signed prekey, one-time prekeys) to the server. This happens in `crypto-service.ts`. If this step fails silently, the user will have an account but no encryption keys, and other users will not be able to initiate encrypted sessions with them. Check the browser console for errors during the key generation step. Common causes include IndexedDB access being blocked (private browsing mode in some browsers restricts IndexedDB), WebCrypto API not being available (requires a secure context), or network errors during the key upload POST request to the `/api/encryption/keys` endpoint. +After WebAuthn registration, the client must generate and upload **public** encryption keys (identity key pair publics, signed prekey + signature, one-time prekey publics) to the server. This happens in `crypto-service.ts:initialize`, which then calls `uploadPublicKeys` → `POST /encryption/upload-keys/{user_id}`. If this step fails silently, the user will have an account but no published prekey bundle, and other users will get a 400 "User has not uploaded keys yet" when trying to initiate an X3DH session. Check the browser console for errors during the key generation step. Common causes include IndexedDB access being blocked (private browsing mode in some browsers restricts IndexedDB), WebCrypto API not being available (requires a secure context), or the session cookie not being sent (verify the request has `credentials: 'include'`, which the shared `api-client.ts` already sets). **Database migration errors:** If you see schema mismatch errors on startup, run migrations first. For Docker: `just migrate head`. For local development: `just migrate-local head`. If you need to start fresh, `just dev-down` followed by removing the Docker volumes will give you a clean database. diff --git a/PROJECTS/advanced/encrypted-p2p-chat/learn/01-CONCEPTS.md b/PROJECTS/advanced/encrypted-p2p-chat/learn/01-CONCEPTS.md index 78647dda..4bfb5db5 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/learn/01-CONCEPTS.md +++ b/PROJECTS/advanced/encrypted-p2p-chat/learn/01-CONCEPTS.md @@ -92,37 +92,37 @@ impossible for the server to read the data in the first place. ### How It Works (in this project) ``` -Alice's Device Server Bob's Device -+----------------+ +----------------+ +----------------+ -| Plaintext | | | | | -| "Hello Bob" | | Encrypted | | Plaintext | -| | | ---> | blob only | ---> | "Hello Bob" | -| v | | No keys | | ^ | -| AES-256-GCM | | No access | | AES-256-GCM | -| encrypt | | No decrypt | | decrypt | -+----------------+ +----------------+ +----------------+ +Alice's Device Server Bob's Device ++----------------+ +----------------+ +----------------+ +| Plaintext | | | | | +| "Hello Bob" | | Encrypted | | Plaintext | +| | | ---> | blob only | ---> | "Hello Bob" | +| v | | No keys | | ^ | +| AES-256-GCM | | No access | | AES-256-GCM | +| encrypt | | No decrypt | | decrypt | ++----------------+ +----------------+ +----------------+ -Keys derived from Server stores Keys derived from -Double Ratchet on ciphertext, nonce, Double Ratchet on -Alice's device and header verbatim Bob's device +Keys derived from Server stores Keys derived from +Double Ratchet on ciphertext, nonce, Double Ratchet on +Alice's device and header verbatim Bob's device ``` -On the server side, `message_service.py:269-314` implements the -`store_encrypted_message` method. Look at the docstring on line 280: +On the server side, `backend/app/services/message_service.py` implements the +`store_encrypted_message` method. Look at the docstring : `"Stores client-encrypted message in SurrealDB (pass-through, no server encryption)"`. The function receives `ciphertext`, `nonce`, and `header` as strings -from the client, and stores them directly in SurrealDB at lines 292-302 +from the client, and stores them directly in SurrealDB without any decryption or re-encryption step. The server is a passthrough. It writes what it receives and reads what it wrote. At no point does it import any cryptographic key or call any decryption function on these message parameters. -On the client side, `crypto-service.ts:217-249` implements the +On the client side, `frontend/src/crypto/crypto-service.ts` implements the `encrypt` method. The client calls `encryptMessage` from -`double-ratchet.ts` at line 229, receives ciphertext and nonce, then +`double-ratchet.ts` , receives ciphertext and nonce, then sends these as base64-encoded strings to the server. The plaintext never leaves the client's process. The actual symmetric encryption -happens in `primitives.ts:224-259` using the WebCrypto API's AES-GCM +happens in `frontend/src/crypto/primitives.ts` using the WebCrypto API's AES-GCM implementation, which runs in the browser's native cryptographic module rather than in JavaScript. This means the plaintext is never even accessible to JavaScript debugging tools during the encryption @@ -166,21 +166,21 @@ solving a different problem: Signal Protocol = X3DH (initial handshake) + Double Ratchet (ongoing encryption) X3DH: "How do Alice and Bob agree on a shared secret - when Bob might be offline?" + when Bob might be offline?" - Solves the ASYNCHRONOUS key agreement problem. - Bob uploads prekey bundles to the server ahead of time. - Alice can start a conversation using Bob's prekeys - without Bob being online. + Solves the ASYNCHRONOUS key agreement problem. + Bob uploads prekey bundles to the server ahead of time. + Alice can start a conversation using Bob's prekeys + without Bob being online. Double Ratchet: "Once they share a secret, how do they - encrypt each message with a UNIQUE key - that can never be recovered?" + encrypt each message with a UNIQUE key + that can never be recovered?" - Solves the FORWARD SECRECY and POST-COMPROMISE - SECURITY problem. Every message gets its own - ephemeral encryption key. Compromising one key - does not reveal past or future messages. + Solves the FORWARD SECRECY and POST-COMPROMISE + SECURITY problem. Every message gets its own + ephemeral encryption key. Compromising one key + does not reveal past or future messages. ``` X3DH runs once at the start of a conversation. The Double Ratchet runs @@ -227,9 +227,9 @@ cryptographic identity. It is generated once and kept for the lifetime of the account. This project generates two identity keypairs per user: - An X25519 keypair for Diffie-Hellman operations - (ref: `x3dh_manager.py:60-86`) + (ref: `frontend/src/crypto/x3dh.ts`) - An Ed25519 keypair for digital signatures - (ref: `x3dh_manager.py:88-114`) + (ref: `frontend/src/crypto/x3dh.ts`) The X25519 keypair participates directly in the DH calculations. The Ed25519 keypair signs the signed prekey to prove it belongs to the same @@ -244,10 +244,10 @@ their identity. The signed prekey is an X25519 keypair that rotates periodically. In this project, rotation happens every 48 hours as configured at -`config.py:76` (`SIGNED_PREKEY_ROTATION_HOURS=48`). +`config.py` (`SIGNED_PREKEY_ROTATION_HOURS=48`). -When a new SPK is generated (`x3dh_manager.py:116-152`), the public -key is signed using the Ed25519 identity key at line 141: +When a new SPK is generated (`frontend/src/crypto/x3dh.ts`), the public +key is signed using the Ed25519 identity key : `signature = identity_private.sign(spk_public_bytes)`. This signature proves that the SPK was created by the holder of the identity key. When Alice downloads Bob's prekey bundle, she verifies this signature before @@ -260,24 +260,23 @@ computed with them become unrecoverable). Longer rotation means fewer key management operations and less complexity. The 48-hour window used here is consistent with Signal's recommendation. -Old signed prekeys are retained for 7 days after deactivation -(`config.py:77`, `SIGNED_PREKEY_RETENTION_DAYS=7`) to allow -messages-in-flight that were encrypted against the old SPK to still be -decrypted. After that retention period, -`prekey_service.py:428-465` (`cleanup_old_signed_prekeys`) deletes them -permanently. +Old signed prekeys are kept around (with `is_active = False`) so +messages-in-flight that were encrypted against the previous SPK can +still complete their initial X3DH on the receiver. There is no +automated reaper in this codebase; pruning very old inactive SPKs is a +suggested extension in `04-CHALLENGES.md`. **One-Time Prekey (OPK) -- Single use, consumed and deleted** One-time prekeys are X25519 keypairs that are used exactly once and then discarded. They are generated in batches -(ref: `x3dh_manager.py:154-174` for generation, -`prekey_service.py:363-407` for batch replenishment) and uploaded to the +(ref: `frontend/src/crypto/x3dh.ts` for generation, +`backend/app/services/prekey_service.py` for batch replenishment) and uploaded to the server. When Alice initiates a conversation with Bob, the server gives Alice one of Bob's unused OPKs and marks it as consumed -(`prekey_service.py:331-338`). This OPK participates in the fourth DH +(`backend/app/services/prekey_service.py`). This OPK participates in the fourth DH operation (DH4) of the X3DH handshake. Because the OPK is used only once and then deleted, it provides an additional layer of forward secrecy specifically for the initial message of a conversation. @@ -287,7 +286,7 @@ other users initiating conversations), X3DH falls back to three DH operations instead of four. The protocol still works, but the initial message has slightly weaker forward secrecy because the fourth DH operation is skipped. The system generates 100 OPKs initially -(`config.py:75`, `DEFAULT_ONE_TIME_PREKEY_COUNT=100`) and replenishes +(`config.py`, `DEFAULT_ONE_TIME_PREKEY_COUNT=100`) and replenishes them when the supply drops below half. **Ephemeral Key (EK) -- Generated per session, never stored** @@ -297,11 +296,11 @@ sender) at the moment she initiates a conversation. It is used in DH2, DH3, and DH4 of the X3DH handshake. It is never stored on disk; it exists only in memory for the duration of the key exchange computation. -In the code, it is generated at `x3dh_manager.py:227-228`: +In the code, it is generated at `frontend/src/crypto/x3dh.ts`: ```python -alice_ek_private = X25519PrivateKey.generate() -alice_ek_public = alice_ek_private.public_key() +alice_ek_private = X25519PrivateKey.generate +alice_ek_public = alice_ek_private.public_key ``` After the shared secret is computed, Alice sends the ephemeral public @@ -319,34 +318,34 @@ The four secrets are concatenated and fed into HKDF to produce the final shared key. ``` -Alice (sender) has: Bob (receiver) has: - IK_A (identity private key) IK_B (identity public key) - EK_A (ephemeral, just generated) SPK_B (signed prekey public) - OPK_B (one-time prekey public) +Alice (sender) has: Bob (receiver) has: + IK_A (identity private key) IK_B (identity public key) + EK_A (ephemeral, just generated) SPK_B (signed prekey public) + OPK_B (one-time prekey public) DH Operations (each produces 32 bytes): - DH1 = X25519(IK_A_private, SPK_B_public) - Alice's identity x Bob's signed prekey + DH1 = X25519(IK_A_private, SPK_B_public) + Alice's identity x Bob's signed prekey - DH2 = X25519(EK_A_private, IK_B_public) - Alice's ephemeral x Bob's identity + DH2 = X25519(EK_A_private, IK_B_public) + Alice's ephemeral x Bob's identity - DH3 = X25519(EK_A_private, SPK_B_public) - Alice's ephemeral x Bob's signed prekey + DH3 = X25519(EK_A_private, SPK_B_public) + Alice's ephemeral x Bob's signed prekey - DH4 = X25519(EK_A_private, OPK_B_public) [optional] - Alice's ephemeral x Bob's one-time prekey + DH4 = X25519(EK_A_private, OPK_B_public) [optional] + Alice's ephemeral x Bob's one-time prekey Key Material Derivation: - input = 0xFF * 32 || DH1 || DH2 || DH3 || DH4 - salt = 0x00 * 32 - info = "X3DH" - SK = HKDF-SHA256(salt, input, info, length=32) + input = 0xFF * 32 || DH1 || DH2 || DH3 || DH4 + salt = 0x00 * 32 + info = "X3DH" + SK = HKDF-SHA256(salt, input, info, length=32) ``` -In the codebase, the sender side is at `x3dh_manager.py:241-264`: +In the codebase, the sender side is at `frontend/src/crypto/x3dh.ts`: - Line 241: `dh1 = alice_ik_private.exchange(bob_spk_public)` -- DH1 - Line 242: `dh2 = alice_ek_private.exchange(bob_ik_public)` -- DH2 @@ -355,15 +354,15 @@ In the codebase, the sender side is at `x3dh_manager.py:241-264`: - Line 252: `key_material = dh1 + dh2 + dh3 + dh4` -- concatenation - Lines 257-264: HKDF derivation with `0xFF * 32` prefix and `b'X3DH'` info string -The receiver side at `x3dh_manager.py:308-333` performs the same +The receiver side at `frontend/src/crypto/x3dh.ts` performs the same operations but with the roles reversed. DH1 becomes -`bob_spk_private.exchange(alice_ik_public)` (line 308), because Bob has +`bob_spk_private.exchange(alice_ik_public)` , because Bob has the SPK private key and Alice's IK public key. The property of Diffie-Hellman guarantees that `X25519(a_priv, B_pub)` produces the same result as `X25519(b_priv, A_pub)`, so both sides compute identical shared secrets. -The `0xFF * 32` prefix prepended at line 257 (`f = b'\xff' * X25519_KEY_SIZE`) +The `0xFF * 32` prefix prepended (`f = b'\xff' * X25519_KEY_SIZE`) is a fixed padding specified by the X3DH standard. It ensures the HKDF input is at least 32 bytes long even in edge cases and provides domain separation from other uses of the same keys. @@ -422,20 +421,20 @@ Before performing any DH operations, Alice must verify that Bob's signed prekey actually belongs to Bob. A malicious server could substitute its own SPK and intercept communications. -The verification happens at `x3dh_manager.py:217-220` inside +The verification happens at `frontend/src/crypto/x3dh.ts` inside `perform_x3dh_sender`: ```python 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") + bob_bundle.signed_prekey_signature, + bob_identity_public_ed25519): + raise ValueError("Invalid signed prekey signature") ``` -The `verify_signed_prekey` method at `x3dh_manager.py:176-206` uses +The `verify_signed_prekey` method at `frontend/src/crypto/x3dh.ts` uses Ed25519 signature verification. It decodes the SPK public key bytes, the signature bytes, and the Ed25519 identity public key bytes, then calls -`identity_public.verify(signature_bytes, spk_public_bytes)` at line 196. +`identity_public.verify(signature_bytes, spk_public_bytes)` . Ed25519 verification either succeeds or raises `InvalidSignature`. If verification fails, the entire X3DH handshake is aborted. @@ -465,15 +464,15 @@ The Double Ratchet provides two critical security properties that go beyond what a static shared key could provide: 1. **Forward secrecy** -- Compromising a current key does not expose - past messages. Even if an attacker steals the current encryption key, - they cannot derive previous keys and therefore cannot decrypt earlier - messages. + past messages. Even if an attacker steals the current encryption key, + they cannot derive previous keys and therefore cannot decrypt earlier + messages. 2. **Post-compromise security (break-in recovery)** -- After a key - compromise, future messages become secure again once a new DH ratchet - step occurs. If an attacker temporarily gains access to key material, - they lose access to the conversation as soon as the keys advance - through a new Diffie-Hellman exchange. + compromise, future messages become secure again once a new DH ratchet + step occurs. If an attacker temporarily gains access to key material, + they lose access to the conversation as soon as the keys advance + through a new Diffie-Hellman exchange. The name "Double Ratchet" refers to the fact that it combines two ratcheting mechanisms: a **DH ratchet** (Diffie-Hellman ratchet) that @@ -487,18 +486,18 @@ the sending chain, and the receiving chain. ``` Root Chain (KDF_RK) - | - |-- [DH ratchet step] --> new root key + new chain key - | - +-- Sending Chain (KDF_CK) - | |-- advance --> Message Key 1 --> Encrypt msg 1 - | |-- advance --> Message Key 2 --> Encrypt msg 2 - | +-- advance --> Message Key 3 --> Encrypt msg 3 - | - +-- Receiving Chain (KDF_CK) - |-- advance --> Message Key 1 --> Decrypt msg 1 - |-- advance --> Message Key 2 --> Decrypt msg 2 - +-- advance --> Message Key 3 --> Decrypt msg 3 + | + |-- [DH ratchet step] --> new root key + new chain key + | + +-- Sending Chain (KDF_CK) + | |-- advance --> Message Key 1 --> Encrypt msg 1 + | |-- advance --> Message Key 2 --> Encrypt msg 2 + | +-- advance --> Message Key 3 --> Encrypt msg 3 + | + +-- Receiving Chain (KDF_CK) + |-- advance --> Message Key 1 --> Decrypt msg 1 + |-- advance --> Message Key 2 --> Decrypt msg 2 + +-- advance --> Message Key 3 --> Decrypt msg 3 ``` Each chain is a sequence of key derivation operations. The root chain @@ -515,20 +514,20 @@ The root chain advances during a DH ratchet step. It takes the current root key and a fresh DH output (from a new DH key exchange) and produces a new root key and a new chain key. -Reference: `double_ratchet.py:79-94` +Reference: `frontend/src/crypto/double-ratchet.ts` ```python def _kdf_rk(self, root_key: bytes, dh_output: bytes) -> tuple[bytes, bytes]: - hkdf = HKDF( - algorithm = hashes.SHA256(), - length = HKDF_OUTPUT_SIZE * 2, # 64 bytes total - salt = root_key, # current root key as salt - info = b'', - ) - output = hkdf.derive(dh_output) - new_root_key = output[: HKDF_OUTPUT_SIZE] # first 32 bytes - new_chain_key = output[HKDF_OUTPUT_SIZE :] # last 32 bytes - return new_root_key, new_chain_key + hkdf = HKDF( + algorithm = hashes.SHA256, + length = HKDF_OUTPUT_SIZE * 2, # 64 bytes total + salt = root_key, # current root key as salt + info = b'', + ) + output = hkdf.derive(dh_output) + new_root_key = output[: HKDF_OUTPUT_SIZE] # first 32 bytes + new_chain_key = output[HKDF_OUTPUT_SIZE :] # last 32 bytes + return new_root_key, new_chain_key ``` HKDF-SHA256 is used with the current root key as the salt and the DH @@ -543,19 +542,19 @@ and the message chains. The symmetric chains advance with every message. Each step takes the current chain key and produces the next chain key and a message key. -Reference: `double_ratchet.py:96-109` +Reference: `frontend/src/crypto/double-ratchet.ts` ```python def _kdf_ck(self, chain_key: bytes) -> tuple[bytes, bytes]: - h_chain = hmac.HMAC(chain_key, hashes.SHA256()) - h_chain.update(b'\x01') - next_chain_key = h_chain.finalize() + 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() + h_message = hmac.HMAC(chain_key, hashes.SHA256) + h_message.update(b'\x02') + message_key = h_message.finalize - return next_chain_key, message_key + return next_chain_key, message_key ``` Two separate HMAC-SHA256 computations are performed using the same chain @@ -563,8 +562,8 @@ key but with different constants: ``` chain_key --+-- HMAC(chain_key, 0x01) --> next_chain_key (kept for future) - | - +-- HMAC(chain_key, 0x02) --> message_key (used once, discarded) + | + +-- HMAC(chain_key, 0x02) --> message_key (used once, discarded) ``` The use of different constants (0x01 and 0x02) is essential. If the same @@ -598,18 +597,18 @@ KDF_RK. Message flow and DH ratchet steps: Alice sends msgs 1,2,3 using DH keypair A1: - A1 --> msg1(mk1), msg2(mk2), msg3(mk3) - [symmetric ratchet advances 3 times, same DH key] + A1 --> msg1(mk1), msg2(mk2), msg3(mk3) + [symmetric ratchet advances 3 times, same DH key] Bob receives, generates new DH keypair B1, sends reply: - DH ratchet: root_key' = KDF_RK(root_key, DH(B1_priv, A1_pub)) - B1 --> msg4(mk1'), msg5(mk2') - [new chain, new keys, A1 compromise no longer helps] + DH ratchet: root_key' = KDF_RK(root_key, DH(B1_priv, A1_pub)) + B1 --> msg4(mk1'), msg5(mk2') + [new chain, new keys, A1 compromise no longer helps] Alice receives, generates new DH keypair A2, sends reply: - DH ratchet: root_key'' = KDF_RK(root_key', DH(A2_priv, B1_pub)) - A2 --> msg6(mk1''), msg7(mk2'') - [new chain again, B1 compromise no longer helps] + DH ratchet: root_key'' = KDF_RK(root_key', DH(A2_priv, B1_pub)) + A2 --> msg6(mk1''), msg7(mk2'') + [new chain again, B1 compromise no longer helps] ``` Each DH ratchet step introduces fresh random entropy (from the newly @@ -618,20 +617,20 @@ even if an attacker had compromised all previous key material, the new DH exchange produces a shared secret they cannot predict, and all subsequent keys are secure again. -The implementation spans `double_ratchet.py:155-213`: +The implementation spans `frontend/src/crypto/double-ratchet.ts`: -- `_dh_ratchet_send` (lines 155-176): Called when the sender needs to - advance the ratchet. Generates a new DH keypair at line 159, performs - DH with the peer's public key at line 169, and derives new root and - sending chain keys at lines 171-174. +- `_dh_ratchet_send` : Called when the sender needs to + advance the ratchet. Generates a new DH keypair , performs + DH with the peer's public key , and derives new root and + sending chain keys . -- `_dh_ratchet_receive` (lines 178-213): Called when a received message - contains a new DH public key. Updates the peer public key at line 189, - performs DH with the existing private key to derive a new receiving - chain key at lines 195-198, then generates a new private key at line - 200 and performs another DH to derive a new sending chain key at lines - 208-211. This double DH step on the receiver side ensures both - receiving and sending chains are updated. +- `_dh_ratchet_receive` : Called when a received message + contains a new DH public key. Updates the peer public key , + performs DH with the existing private key to derive a new receiving + chain key , then generates a new private key at line + 200 and performs another DH to derive a new sending chain key at lines + 208-211. This double DH step on the receiver side ensures both + receiving and sending chains are updated. ### Out-of-Order Message Handling @@ -644,17 +643,17 @@ Internet messages can arrive out of order. If Alice sends messages 1, 2, 4. Derive mk3 and decrypt message 3 The skipped message key mechanism handles this. Reference: -`double_ratchet.py:215-277`. +`frontend/src/crypto/double-ratchet.ts`. -`_store_skipped_message_keys` (lines 215-244) is called when the +`_store_skipped_message_keys` is called when the received message number is greater than the expected message number. It iterates through the gap, deriving and caching each skipped message key: ```python 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 + 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 ``` @@ -663,24 +662,24 @@ message_number)`. This tuple key is necessary because message numbers reset with each DH ratchet step: message 0 under DH key A1 is different from message 0 under DH key A2. -`_try_skipped_message_key` (lines 260-277) checks whether a received +`_try_skipped_message_key` checks whether a received message matches a previously cached skipped key. If it does, the cached key is used for decryption and then removed from the cache (it is -consumed by `dict.pop()` at line 270). +consumed by `dict.pop` ). Security limits prevent abuse. An attacker who sends messages with enormous message numbers could force the ratchet to derive and store millions of keys, exhausting memory. Two limits are enforced: -- `MAX_SKIP_MESSAGE_KEYS = 1000` (`config.py:73`): No more than 1000 - message keys can be skipped in a single gap. If a message arrives - claiming to be message number 5000 when we expect message 0, the - decryption is rejected at lines 226-230. +- `MAX_SKIP_MESSAGE_KEYS = 1000` (`config.py`): No more than 1000 + message keys can be skipped in a single gap. If a message arrives + claiming to be message number 5000 when we expect message 0, the + decryption is rejected . -- `MAX_CACHED_MESSAGE_KEYS = 2000` (`config.py:74`): The total number - of cached skipped keys across all ratchet epochs. If the cache is - full, the oldest keys are evicted at lines 232-234 via - `_evict_oldest_skipped_keys`. +- `MAX_CACHED_MESSAGE_KEYS = 2000` (`config.py`): The total number + of cached skipped keys across all ratchet epochs. If the cache is + full, the oldest keys are evicted via + `_evict_oldest_skipped_keys`. ### Forward Secrecy Proof @@ -691,25 +690,25 @@ Assume at message N, an attacker steals the current chain_key_N. ``` What the attacker CAN compute (forward direction): - chain_key_N -----> HMAC(chain_key_N, 0x01) = chain_key_N+1 - chain_key_N+1 ---> HMAC(chain_key_N+1, 0x01) = chain_key_N+2 - ... and so on for all future chain keys + chain_key_N -----> HMAC(chain_key_N, 0x01) = chain_key_N+1 + chain_key_N+1 ---> HMAC(chain_key_N+1, 0x01) = chain_key_N+2 + ... and so on for all future chain keys What the attacker CANNOT compute (backward direction): - chain_key_N <-/-- chain_key_N-1 + chain_key_N <-/-- chain_key_N-1 - Why? Because HMAC is a one-way function. + Why? Because HMAC is a one-way function. - chain_key_N = HMAC(chain_key_N-1, 0x01) + chain_key_N = HMAC(chain_key_N-1, 0x01) - Given chain_key_N, you cannot solve for chain_key_N-1. - This would require inverting HMAC-SHA256, which is - computationally infeasible (preimage resistance). + Given chain_key_N, you cannot solve for chain_key_N-1. + This would require inverting HMAC-SHA256, which is + computationally infeasible (preimage resistance). Therefore: - message_key_N-1 = HMAC(chain_key_N-1, 0x02) <-- UNREACHABLE - message_key_N-2 = HMAC(chain_key_N-2, 0x02) <-- UNREACHABLE - message_key_1 = HMAC(chain_key_1, 0x02) <-- UNREACHABLE + message_key_N-1 = HMAC(chain_key_N-1, 0x02) <-- UNREACHABLE + message_key_N-2 = HMAC(chain_key_N-2, 0x02) <-- UNREACHABLE + message_key_1 = HMAC(chain_key_1, 0x02) <-- UNREACHABLE ``` The attacker can decrypt messages N+1, N+2, N+3, and so on (until the @@ -752,46 +751,46 @@ The encryption flow is: 1. The Double Ratchet derives a message key (32 bytes) via KDF_CK 2. A random 12-byte nonce is generated using `os.urandom` (backend) or - `crypto.getRandomValues` (frontend) + `crypto.getRandomValues` (frontend) 3. AES-256-GCM encrypts the plaintext using the message key and nonce 4. Associated data (sender and recipient identifiers) is authenticated - but not encrypted + but not encrypted 5. The output is ciphertext + a 16-byte authentication tag (GCM appends - the tag to the ciphertext) + the tag to the ciphertext) -Backend implementation at `double_ratchet.py:111-130`: +Backend implementation at `frontend/src/crypto/double-ratchet.ts`: ```python def _encrypt_with_message_key(self, message_key, plaintext, associated_data): - aesgcm = AESGCM(message_key) - nonce = os.urandom(AES_GCM_NONCE_SIZE) # 12 bytes from config.py:69 - ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data) - return nonce, ciphertext + aesgcm = AESGCM(message_key) + nonce = os.urandom(AES_GCM_NONCE_SIZE) # 12 bytes from config.py + ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data) + return nonce, ciphertext ``` -Backend decryption at `double_ratchet.py:132-153` catches `InvalidTag` -exceptions (line 151), which indicate that the ciphertext was tampered +Backend decryption at `frontend/src/crypto/double-ratchet.ts` catches `InvalidTag` +exceptions , which indicate that the ciphertext was tampered with, the wrong key was used, or the associated data does not match. The error is re-raised as `ValueError("Message tampered or corrupted")` -at line 153. +. -Frontend implementation at `primitives.ts:224-259` uses the WebCrypto +Frontend implementation at `frontend/src/crypto/primitives.ts` uses the WebCrypto API: ```typescript const nonce = generateRandomBytes(AES_GCM_NONCE_SIZE) const ciphertext = await subtle.encrypt( - { - name: "AES-GCM", - iv: nonce.buffer, - additionalData: associatedData?.buffer, - }, - cryptoKey, - plaintext.buffer + { + name: "AES-GCM", + iv: nonce.buffer, + additionalData: associatedData?.buffer, + }, + cryptoKey, + plaintext.buffer ) ``` -Frontend decryption at `primitives.ts:261-292` mirrors this with +Frontend decryption at `frontend/src/crypto/primitives.ts` mirrors this with `subtle.decrypt`. The WebCrypto API throws a `DOMException` if authentication fails, which is functionally equivalent to the Python `InvalidTag` exception. @@ -817,12 +816,12 @@ security reason to prefer CBC over GCM for new implementations. ### Parameters -As defined in `config.py:68-70`: +As defined in `config.py`: ``` -AES_GCM_KEY_SIZE = 32 (256 bits) line 68 -AES_GCM_NONCE_SIZE = 12 (96 bits) line 69 -HKDF_OUTPUT_SIZE = 32 (256 bits) line 70 +AES_GCM_KEY_SIZE = 32 (256 bits) +AES_GCM_NONCE_SIZE = 12 (96 bits) +HKDF_OUTPUT_SIZE = 32 (256 bits) ``` The 12-byte (96-bit) nonce is the recommended size for GCM. Longer @@ -913,44 +912,44 @@ and you cannot forge a signature without the private key. ``` Step 1: Client requests registration options - Client ----> POST /auth/register/begin ----> Server + Client ----> POST /auth/register/begin ----> Server - Server: - - Generates 32-byte random challenge (secrets.token_bytes) - - Stores challenge in Redis with 10-minute TTL - - Returns WebAuthn PublicKeyCredentialCreationOptions + Server: + - Generates 32-byte random challenge (secrets.token_bytes) + - Stores challenge in Redis with 10-minute TTL + - Returns WebAuthn PublicKeyCredentialCreationOptions Step 2: Browser creates credential - Browser ----> navigator.credentials.create(options) ----> Authenticator + Browser ----> navigator.credentials.create(options) ----> Authenticator - Authenticator: - - Prompts user for biometric/PIN verification - - Generates new ECDSA or EdDSA keypair - - Stores private key internally (NEVER exported) - - Returns attestation object (signed credential public key) + Authenticator: + - Prompts user for biometric/PIN verification + - Generates new ECDSA or EdDSA keypair + - Stores private key internally (NEVER exported) + - Returns attestation object (signed credential public key) Step 3: Client sends attestation for verification - Client ----> POST /auth/register/complete ----> Server + Client ----> POST /auth/register/complete ----> Server - Server: - - Verifies attestation signature - - Verifies challenge matches stored value - - Extracts credential public key and credential ID - - Stores PUBLIC KEY + credential ID in PostgreSQL - - Deletes challenge from Redis - - Private key STAYS on authenticator -- server never sees it + Server: + - Verifies attestation signature + - Verifies challenge matches stored value + - Extracts credential public key and credential ID + - Stores PUBLIC KEY + credential ID in PostgreSQL + - Deletes challenge from Redis + - Private key STAYS on authenticator -- server never sees it ``` -Implementation reference: `passkey_manager.py:55-94` -(`generate_registration_options`). At line 65, a 32-byte challenge is +Implementation reference: `backend/app/core/passkey/passkey_manager.py` +(`generate_registration_options`). a 32-byte challenge is generated: `challenge = secrets.token_bytes(WEBAUTHN_CHALLENGE_BYTES)`. At lines 74-87, the WebAuthn options are constructed with RP configuration, user information, and authenticator requirements. The -authenticator selection at lines 82-85 specifies +authenticator selection specifies `ResidentKeyRequirement.REQUIRED`, which forces creation of a discoverable credential (passkey). -Registration verification at `passkey_manager.py:96-130` +Registration verification at `backend/app/core/passkey/passkey_manager.py` (`verify_registration`) calls `verify_registration_response` at lines 105-110, which validates the attestation object, checks the challenge, verifies the RP ID, and confirms the origin. @@ -959,40 +958,40 @@ verifies the RP ID, and confirms the origin. ``` Step 1: Client requests authentication options - Client ----> POST /auth/authenticate/begin ----> Server + Client ----> POST /auth/authenticate/begin ----> Server - Server: - - Generates new 32-byte challenge - - Stores challenge in Redis with 10-minute TTL - - Returns WebAuthn PublicKeyCredentialRequestOptions + Server: + - Generates new 32-byte challenge + - Stores challenge in Redis with 10-minute TTL + - Returns WebAuthn PublicKeyCredentialRequestOptions Step 2: Browser signs challenge - Browser ----> navigator.credentials.get(options) ----> Authenticator + Browser ----> navigator.credentials.get(options) ----> Authenticator - Authenticator: - - Prompts user for biometric/PIN - - Signs challenge with stored private key - - Increments signature counter - - Returns assertion (signed challenge + counter) + Authenticator: + - Prompts user for biometric/PIN + - Signs challenge with stored private key + - Increments signature counter + - Returns assertion (signed challenge + counter) Step 3: Client sends assertion for verification - Client ----> POST /auth/authenticate/complete ----> Server + Client ----> POST /auth/authenticate/complete ----> Server - Server: - - Retrieves stored public key from PostgreSQL - - Verifies signature using stored public key - - Verifies challenge matches stored value - - Checks signature counter INCREASED (clone detection!) - - Updates stored counter value - - Returns authenticated session + Server: + - Retrieves stored public key from PostgreSQL + - Verifies signature using stored public key + - Verifies challenge matches stored value + - Checks signature counter INCREASED (clone detection!) + - Updates stored counter value + - Returns authenticated session ``` -Implementation reference: `passkey_manager.py:132-160` -(`generate_authentication_options`). At line 139, a fresh challenge is +Implementation reference: `backend/app/core/passkey/passkey_manager.py` +(`generate_authentication_options`). a fresh challenge is generated. At lines 148-153, WebAuthn authentication options are constructed. -Authentication verification at `passkey_manager.py:162-207` +Authentication verification at `backend/app/core/passkey/passkey_manager.py` (`verify_authentication`). At lines 173-180, the assertion is verified against the expected challenge, RP ID, origin, and stored credential public key. The critical clone detection check follows. @@ -1009,26 +1008,26 @@ If the server receives an assertion with a counter value that has not increased (or has decreased), it indicates one of two things: 1. The authenticator hardware was cloned (its key material was - extracted and loaded onto a second device) + extracted and loaded onto a second device) 2. A replay attack is being attempted Both scenarios are security incidents that warrant blocking authentication and alerting the user. -Reference: `passkey_manager.py:184-193`: +Reference: `backend/app/core/passkey/passkey_manager.py`: ```python if (credential_current_sign_count != 0 and new_sign_count != 0 - and new_sign_count <= credential_current_sign_count): - logger.error( - "Signature counter did not increase: current=%s, new=%s. " - "Possible cloned authenticator detected!", - credential_current_sign_count, - new_sign_count - ) - raise ValueError( - "Signature counter anomaly detected - potential cloned authenticator" - ) + and new_sign_count <= credential_current_sign_count): + logger.error( + "Signature counter did not increase: current=%s, new=%s. " + "Possible cloned authenticator detected!", + credential_current_sign_count, + new_sign_count + ) + raise ValueError( + "Signature counter anomaly detected - potential cloned authenticator" + ) ``` The conditions `credential_current_sign_count != 0` and @@ -1043,17 +1042,17 @@ support signature counters. ## Constant-Time Comparison -A detail worth calling out: `primitives.ts:388-397` implements a +A detail worth calling out: `frontend/src/crypto/primitives.ts` implements a constant-time byte array comparison: ```typescript export function constantTimeEqual(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 + 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 } ``` @@ -1080,72 +1079,72 @@ the full security architecture of the application: ``` +-----------------------------------------------------------+ -| AUTHENTICATION LAYER | -| | -| WebAuthn/Passkeys | -| | | -| +--> User identity established | -| +--> No password to steal, phish, or brute force | -| +--> Clone detection via signature counter | -| | | -| v | -| +----------------------------------------------------+ | -| | KEY AGREEMENT LAYER | | -| | | | -| | X3DH Key Exchange | | -| | +--> Asynchronous (works when peer is offline) | | -| | +--> 4 DH operations for mutual authentication | | -| | +--> Produces initial shared secret (32 bytes) | | -| | | | | -| | v | | -| | Double Ratchet Initialization | | -| | +--> Shared secret becomes root key | | -| | +--> Sending and receiving chains created | | -| +----------------------------------------------------+ | -| | | -| v | -| +----------------------------------------------------+ | -| | MESSAGE ENCRYPTION LAYER | | -| | | | -| | Double Ratchet (ongoing) | | -| | +--> KDF_CK derives per-message keys | | -| | +--> DH ratchet provides post-compromise security| | -| | +--> Skipped key cache handles out-of-order msgs | | -| | | | | -| | v | | -| | AES-256-GCM | | -| | +--> Encrypts plaintext with message key | | -| | +--> Random 12-byte nonce per message | | -| | +--> Authentication tag detects tampering | | -| +----------------------------------------------------+ | -| | | -| v | -| +----------------------------------------------------+ | -| | TRANSPORT LAYER | | -| | | | -| | WebSocket (real-time delivery) | | -| | +--> Carries encrypted blobs between clients | | -| | +--> Server sees only ciphertext + metadata | | -| | | | -| | SurrealDB (persistence) | | -| | +--> Stores encrypted messages at rest | | -| | +--> No decryption capability on server | | -| +----------------------------------------------------+ | +| AUTHENTICATION LAYER | +| | +| WebAuthn/Passkeys | +| | | +| +--> User identity established | +| +--> No password to steal, phish, or brute force | +| +--> Clone detection via signature counter | +| | | +| v | +| +----------------------------------------------------+ | +| | KEY AGREEMENT LAYER | | +| | | | +| | X3DH Key Exchange | | +| | +--> Asynchronous (works when peer is offline) | | +| | +--> 4 DH operations for mutual authentication | | +| | +--> Produces initial shared secret (32 bytes) | | +| | | | | +| | v | | +| | Double Ratchet Initialization | | +| | +--> Shared secret becomes root key | | +| | +--> Sending and receiving chains created | | +| +----------------------------------------------------+ | +| | | +| v | +| +----------------------------------------------------+ | +| | MESSAGE ENCRYPTION LAYER | | +| | | | +| | Double Ratchet (ongoing) | | +| | +--> KDF_CK derives per-message keys | | +| | +--> DH ratchet provides post-compromise security| | +| | +--> Skipped key cache handles out-of-order msgs | | +| | | | | +| | v | | +| | AES-256-GCM | | +| | +--> Encrypts plaintext with message key | | +| | +--> Random 12-byte nonce per message | | +| | +--> Authentication tag detects tampering | | +| +----------------------------------------------------+ | +| | | +| v | +| +----------------------------------------------------+ | +| | TRANSPORT LAYER | | +| | | | +| | WebSocket (real-time delivery) | | +| | +--> Carries encrypted blobs between clients | | +| | +--> Server sees only ciphertext + metadata | | +| | | | +| | SurrealDB (persistence) | | +| | +--> Stores encrypted messages at rest | | +| | +--> No decryption capability on server | | +| +----------------------------------------------------+ | +-----------------------------------------------------------+ ``` The layers interact in a strict top-down sequence for new conversations: 1. The user authenticates with WebAuthn (proving their identity without - a password) + a password) 2. X3DH establishes a shared secret with the peer (even if the peer is - offline) + offline) 3. The shared secret initializes the Double Ratchet - (`double_ratchet.py:279-302`) + (`frontend/src/crypto/double-ratchet.ts`) 4. Each message is encrypted with a unique AES-256-GCM key derived from - the ratchet (`double_ratchet.py:323-362`) + the ratchet (`frontend/src/crypto/double-ratchet.ts`) 5. The encrypted message is transmitted via WebSocket and stored in - SurrealDB (`message_service.py:269-314`) + SurrealDB (`backend/app/services/message_service.py`) For ongoing conversations, only steps 4 and 5 repeat. The X3DH handshake happens once per conversation. The Double Ratchet then runs @@ -1224,11 +1223,11 @@ well-tested implementations. 112 bits for use through 2030+ (NIST SP 800-57 Part 1). **CWE-330: Use of Insufficiently Random Values** -- Randomness comes -from two sources: `os.urandom()` on the backend (which reads from the +from two sources: `os.urandom` on the backend (which reads from the operating system's CSPRNG -- `/dev/urandom` on Linux) and -`crypto.getRandomValues()` on the frontend (which uses the browser's +`crypto.getRandomValues` on the frontend (which uses the browser's CSPRNG). Both are cryptographically secure. Nonce generation at -`double_ratchet.py:122` and `primitives.ts:294-297` use these +`frontend/src/crypto/double-ratchet.ts` and `frontend/src/crypto/primitives.ts` use these exclusively. **CWE-311: Missing Encryption of Sensitive Data** -- All message content @@ -1268,20 +1267,20 @@ cryptography textbook. server never generates or holds encryption keys for message content. Key generation happens in two places: -1. On the backend, `x3dh_manager.py:60-86` generates X25519 identity - keypairs using `X25519PrivateKey.generate()`, which calls into - OpenSSL's random number generator. These keys are for the X3DH - protocol, and the private keys are stored in the database for the - server-side key exchange path. +1. On the backend, `frontend/src/crypto/x3dh.ts` generates X25519 identity + keypairs using `X25519PrivateKey.generate`, which calls into + OpenSSL's random number generator. These keys are for the X3DH + protocol, and the private keys are stored in the database for the + server-side key exchange path. -2. On the frontend, `primitives.ts:15-24` generates X25519 keypairs - using the WebCrypto API (`subtle.generateKey`), which uses the - browser's hardware-backed CSPRNG. In the client-side encryption - model, these keys never leave the browser. +2. On the frontend, `frontend/src/crypto/primitives.ts` generates X25519 keypairs + using the WebCrypto API (`subtle.generateKey`), which uses the + browser's hardware-backed CSPRNG. In the client-side encryption + model, these keys never leave the browser. -The `store_encrypted_message` function (`message_service.py:269-314`) +The `store_encrypted_message` function (`backend/app/services/message_service.py`) receives pre-encrypted ciphertext from the client and stores it directly -in SurrealDB at lines 292-302 without any server-side decryption. The +in SurrealDB without any server-side decryption. The server's role is explicitly that of a blind relay. Even if the entire server infrastructure were compromised, the attacker would obtain only encrypted blobs with no corresponding decryption keys. @@ -1302,7 +1301,7 @@ Ratchet combination used in this project. Each message gets a unique AES key through the ratchet mechanism, providing forward secrecy across billions of daily messages. WhatsApp's implementation stores prekey bundles on their servers (just as this project does via -`prekey_service.py:293-361`), allowing asynchronous session +`backend/app/services/prekey_service.py`), allowing asynchronous session establishment. The X3DH handshake is performed when a user initiates a new conversation, and the Double Ratchet runs continuously thereafter. @@ -1315,12 +1314,12 @@ same situation occurred with FBI requests in the United States and government demands in India and the UK. **Connection to this project.** The X3DH implementation at -`x3dh_manager.py:208-281` and the Double Ratchet at -`double_ratchet.py:279-416` implement the same cryptographic operations +`frontend/src/crypto/x3dh.ts` and the Double Ratchet at +`frontend/src/crypto/double-ratchet.ts` implement the same cryptographic operations described in the Signal Protocol specification. The same four DH -operations (lines 241-252), the same HKDF derivation (lines 257-264), -the same KDF chain operations (lines 79-109 of double_ratchet.py), and -the same skipped message key mechanism (lines 215-277). The protocol +operations , the same HKDF derivation , +the same KDF chain operations (lines 79-109 of frontend/src/crypto/double-ratchet.ts), and +the same skipped message key mechanism . The protocol specifications are public, the formal security proofs are published, and the implementation follows them directly. @@ -1351,7 +1350,7 @@ authentication, even when the passwords are used to derive encryption keys. In this project's WebAuthn implementation -(`passkey_manager.py:55-94`), there is no password. The user +(`backend/app/core/passkey/passkey_manager.py`), there is no password. The user authenticates with a biometric or PIN on their authenticator device. The authenticator holds an ECDSA or EdDSA private key that is hardware-bound and never exported. If the server database is fully compromised, the @@ -1375,27 +1374,27 @@ answer these questions. If you cannot answer one confidently, re-read the relevant section. 1. **Why does X3DH need four separate DH operations instead of just - one?** What specific security property does each operation provide? - What attack becomes possible if any single operation is removed? + one?** What specific security property does each operation provide? + What attack becomes possible if any single operation is removed? 2. **If an attacker compromises a Double Ratchet chain key at message N, - which messages can they decrypt?** Which messages remain protected? - When does the attacker lose access? Trace through the KDF_CK function - to prove your answer. + which messages can they decrypt?** Which messages remain protected? + When does the attacker lose access? Trace through the KDF_CK function + to prove your answer. 3. **Why is WebAuthn resistant to phishing attacks, while traditional - passwords are not?** What property of the WebAuthn protocol prevents - a fake login page from capturing usable credentials? Hint: think - about what the authenticator checks before signing the challenge. + passwords are not?** What property of the WebAuthn protocol prevents + a fake login page from capturing usable credentials? Hint: think + about what the authenticator checks before signing the challenge. 4. **What happens if Alice sends Bob messages 1, 2, 3, 4, 5 and Bob - receives them in order 1, 3, 5, 2, 4?** Walk through the skipped - message key mechanism for each received message. How many keys are - cached after processing message 3? After processing message 5? + receives them in order 1, 3, 5, 2, 4?** Walk through the skipped + message key mechanism for each received message. How many keys are + cached after processing message 3? After processing message 5? 5. **Why does the Double Ratchet use two different HMAC constants (0x01 - and 0x02) in KDF_CK?** What would go wrong if both the chain key and - the message key were derived with the same constant? + and 0x02) in KDF_CK?** What would go wrong if both the chain key and + the message key were derived with the same constant? ## Further Reading @@ -1410,58 +1409,58 @@ relevant section. ### Academic Analysis - "A Formal Security Analysis of the Signal Messaging Protocol" -- - Cohn-Gordon, Cremers, Dowling, Garratt, Stebila. IEEE EuroS&P 2017. - Formal proof that the Signal Protocol meets its claimed security - properties (authenticated key exchange, forward secrecy, post-compromise - security) under the Gap-DH assumption. + Cohn-Gordon, Cremers, Dowling, Garratt, Stebila. IEEE EuroS&P 2017. + Formal proof that the Signal Protocol meets its claimed security + properties (authenticated key exchange, forward secrecy, post-compromise + security) under the Gap-DH assumption. - "The Signal Protocol: A Cryptographic Analysis" -- Cohn-Gordon et al. - (2017). Extended version with proofs covering the X3DH and Double - Ratchet components individually and composed. + (2017). Extended version with proofs covering the X3DH and Double + Ratchet components individually and composed. - "On Ends-to-Ends Encryption: Asynchronous Group Messaging with Strong - Security Guarantees" -- Cohn-Gordon et al. IEEE S&P 2018. Extends the - analysis to group messaging scenarios. + Security Guarantees" -- Cohn-Gordon et al. IEEE S&P 2018. Extends the + analysis to group messaging scenarios. ### FIDO and WebAuthn - FIDO2 technical overview: https://fidoalliance.org/fido2/ - FIDO Alliance whitepaper on passkeys: - https://fidoalliance.org/passkeys/ + https://fidoalliance.org/passkeys/ - NIST SP 800-63B Digital Identity Guidelines (authenticator types and - assurance levels) + assurance levels) ### Cryptographic Primitives - Daniel J. Bernstein, "Curve25519: new Diffie-Hellman speed records" - (2006) -- The paper introducing the X25519 function used for DH in - this project. + (2006) -- The paper introducing the X25519 function used for DH in + this project. - Daniel J. Bernstein, Niels Duif, Tanja Lange, Peter Schwabe, Bo-Yin - Yang, "High-speed high-security signatures" (2012) -- The paper - introducing Ed25519 used for signing prekeys. + Yang, "High-speed high-security signatures" (2012) -- The paper + introducing Ed25519 used for signing prekeys. - NIST SP 800-38D, "Recommendation for Block Cipher Modes of Operation: - Galois/Counter Mode (GCM) and GMAC" -- The specification for the - AES-GCM mode used for message encryption. + Galois/Counter Mode (GCM) and GMAC" -- The specification for the + AES-GCM mode used for message encryption. - Hugo Krawczyk, "Cryptographic Extraction and Key Derivation: The HKDF - Scheme" (2010) -- The paper behind HKDF, the key derivation function - used throughout the Double Ratchet. + Scheme" (2010) -- The paper behind HKDF, the key derivation function + used throughout the Double Ratchet. ### Historical Context - Nikita Borisov, Ian Goldberg, Eric Brewer, "Off-the-Record - Communication, or, Why Not To Use PGP" (2004) -- The OTR protocol - that introduced the concept of deniable, forward-secret messaging and - directly inspired the Signal Protocol's design. + Communication, or, Why Not To Use PGP" (2004) -- The OTR protocol + that introduced the concept of deniable, forward-secret messaging and + directly inspired the Signal Protocol's design. - Whitfield Diffie and Martin Hellman, "New Directions in Cryptography" - (1976) -- The original paper introducing public key cryptography and - the Diffie-Hellman key exchange that underlies X3DH. + (1976) -- The original paper introducing public key cryptography and + the Diffie-Hellman key exchange that underlies X3DH. - Phil Zimmermann and PGP: The "Crypto Wars" of the 1990s, where the - US government attempted to restrict the export of strong cryptography. - Zimmermann published PGP's source code in a printed book to circumvent - export controls under First Amendment protection. This history is why - cryptographic software can be freely distributed today. + US government attempted to restrict the export of strong cryptography. + Zimmermann published PGP's source code in a printed book to circumvent + export controls under First Amendment protection. This history is why + cryptographic software can be freely distributed today. diff --git a/PROJECTS/advanced/encrypted-p2p-chat/learn/02-ARCHITECTURE.md b/PROJECTS/advanced/encrypted-p2p-chat/learn/02-ARCHITECTURE.md index d87f8d60..6c56d9a3 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/learn/02-ARCHITECTURE.md +++ b/PROJECTS/advanced/encrypted-p2p-chat/learn/02-ARCHITECTURE.md @@ -9,57 +9,57 @@ Everything that follows is grounded in the actual source code. File references u ## High-Level Architecture ``` - HTTPS (TLS) - ┌────────────────────────────────────────────────────────────┐ - │ Client Browser │ - │ ┌──────────────────────────────────────────────────────┐ │ - │ │ SolidJS 1.9 + TypeScript 5.9 │ │ - │ │ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ │ - │ │ │ WebCrypto │ │ IndexedDB │ │ nanostores │ │ │ - │ │ │ X25519 │ │ Private │ │ Reactive │ │ │ - │ │ │ Ed25519 │ │ Keys │ │ State │ │ │ - │ │ │ AES-256-GCM │ │ Ratchet │ │ │ │ │ - │ │ │ HKDF-SHA256 │ │ States │ │ │ │ │ - │ │ └──────────────┘ └──────────────┘ └────────────┘ │ │ - │ └──────────────────────────┬───────────────────────────┘ │ - │ │ │ - └──────────────────────────────┼──────────────────────────────┘ - │ - HTTPS + WSS │ - │ - ┌──────────────────────────────┼──────────────────────────────┐ - │ Nginx Reverse Proxy │ │ - │ ┌───────────────────────────┴──────────────────────────┐ │ - │ │ /api/* ──────► HTTP ──────► FastAPI :8000 │ │ - │ │ /ws ──────► WS ──────► FastAPI :8000 │ │ - │ │ /* ──────► Static files (SolidJS build) │ │ - │ └──────────────────────────────────────────────────────┘ │ - └──────────────────────────────┬──────────────────────────────┘ - │ - ┌──────────────────────────────┼──────────────────────────────┐ - │ FastAPI Backend │ │ - │ (Python 3.13, ORJSONResponse, async/await throughout) │ - │ ┌──────────────────────────────────────────────────────┐ │ - │ │ Routers: auth, rooms, encryption, websocket │ │ - │ │ Services: auth, prekey, message, presence, websocket│ │ - │ │ Core: x3dh, double_ratchet, passkey, surreal, redis │ │ - │ └────────┬──────────────┬──────────────┬───────────────┘ │ - │ │ │ │ │ - └───────────┼──────────────┼──────────────┼───────────────────┘ - │ │ │ - asyncpg │ WebSocket │ redis. │ - (TCP 5432) │ (WS 8000) │ asyncio │ - │ │ (TCP 6379) │ - ┌───────────┴──┐ ┌───────┴──────┐ ┌───┴────────────┐ - │ PostgreSQL │ │ SurrealDB │ │ Redis 8 │ - │ 16-alpine │ │ (latest) │ │ (alpine) │ - │ │ │ │ │ │ - │ Auth data │ │ Messages │ │ Challenges │ - │ Credentials │ │ Presence │ │ Rate limits │ - │ X3DH keys │ │ Rooms │ │ │ - │ Ratchet │ │ Live │ │ TTL-based │ - │ states │ │ queries │ │ auto-expiry │ - └──────────────┘ └──────────────┘ └────────────────┘ + HTTPS (TLS) + ┌────────────────────────────────────────────────────────────┐ + │ Client Browser │ + │ ┌──────────────────────────────────────────────────────┐ │ + │ │ SolidJS 1.9 + TypeScript 5.9 │ │ + │ │ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ │ + │ │ │ WebCrypto │ │ IndexedDB │ │ nanostores │ │ │ + │ │ │ X25519 │ │ Private │ │ Reactive │ │ │ + │ │ │ Ed25519 │ │ Keys │ │ State │ │ │ + │ │ │ AES-256-GCM │ │ Ratchet │ │ │ │ │ + │ │ │ HKDF-SHA256 │ │ States │ │ │ │ │ + │ │ └──────────────┘ └──────────────┘ └────────────┘ │ │ + │ └──────────────────────────┬───────────────────────────┘ │ + │ │ │ + └──────────────────────────────┼──────────────────────────────┘ + │ + HTTPS + WSS │ + │ + ┌──────────────────────────────┼──────────────────────────────┐ + │ Nginx Reverse Proxy │ │ + │ ┌───────────────────────────┴──────────────────────────┐ │ + │ │ /api/* ──────► HTTP ──────► FastAPI :8000 │ │ + │ │ /ws ──────► WS ──────► FastAPI :8000 │ │ + │ │ /* ──────► Static files (SolidJS build) │ │ + │ └──────────────────────────────────────────────────────┘ │ + └──────────────────────────────┬──────────────────────────────┘ + │ + ┌──────────────────────────────┼──────────────────────────────┐ + │ FastAPI Backend │ │ + │ (Python 3.13, ORJSONResponse, async/await throughout) │ + │ ┌──────────────────────────────────────────────────────┐ │ + │ │ Routers: auth, rooms, encryption, websocket │ │ + │ │ Services: auth, prekey, message, presence, websocket│ │ + │ │ Core: x3dh, double_ratchet, passkey, surreal, redis │ │ + │ └────────┬──────────────┬──────────────┬───────────────┘ │ + │ │ │ │ │ + └───────────┼──────────────┼──────────────┼───────────────────┘ + │ │ │ + asyncpg │ WebSocket │ redis. │ + (TCP 5432) │ (WS 8000) │ asyncio │ + │ │ (TCP 6379) │ + ┌───────────┴──┐ ┌───────┴──────┐ ┌───┴────────────┐ + │ PostgreSQL │ │ SurrealDB │ │ Redis 8 │ + │ 16-alpine │ │ (latest) │ │ (alpine) │ + │ │ │ │ │ │ + │ Auth data │ │ Messages │ │ Challenges │ + │ Credentials │ │ Presence │ │ Rate limits │ + │ X3DH keys │ │ Rooms │ │ │ + │ Ratchet │ │ Live │ │ TTL-based │ + │ states │ │ queries │ │ auto-expiry │ + └──────────────┘ └──────────────┘ └────────────────┘ ``` A few things worth noting about this diagram: @@ -80,38 +80,38 @@ A few things worth noting about this diagram: **Key File:** `factory.py` -The application uses the factory pattern. `create_app()` at lines 63-115 builds the FastAPI instance: +The application uses the factory pattern. `create_app` builds the FastAPI instance: ``` -create_app() (factory.py:63-115) - ├── FastAPI( - │ title = settings.APP_NAME, - │ default_response_class = ORJSONResponse, - │ lifespan = lifespan, - │ ) - ├── CORSMiddleware (factory.py:78-85) - ├── GZipMiddleware, minimum_size=1000 (factory.py:87) - ├── register_exception_handlers (factory.py:89) - ├── Root endpoint "/" (factory.py:91-101) - ├── Health endpoint "/health" (factory.py:103-108) - └── Routers: - ├── auth_router → /auth/* - ├── rooms_router → /rooms/* - ├── encryption_router → /encryption/* - └── websocket_router → /ws +create_app (factory.py) + ├── FastAPI( + │ title = settings.APP_NAME, + │ default_response_class = ORJSONResponse, + │ lifespan = lifespan, + │ ) + ├── CORSMiddleware (factory.py) + ├── GZipMiddleware, minimum_size=1000 (factory.py) + ├── register_exception_handlers (factory.py) + ├── Root endpoint "/" (factory.py) + ├── Health endpoint "/health" (factory.py) + └── Routers: + ├── auth_router → /auth/* + ├── rooms_router → /rooms/* + ├── encryption_router → /encryption/* + └── websocket_router → /ws ``` -The lifespan manager at lines 39-61 runs database connections on startup and disconnections on shutdown: +The lifespan manager runs database connections on startup and disconnections on shutdown: ``` -lifespan(app) (factory.py:39-61) - Startup: - 1. await init_db() → PostgreSQL tables via SQLModel - 2. await redis_manager.connect() → Redis connection pool - 3. await surreal_db.connect() → SurrealDB WebSocket connection - Shutdown: - 1. await redis_manager.disconnect() - 2. await surreal_db.disconnect() +lifespan(app) (factory.py) + Startup: + 1. await init_db → PostgreSQL tables via SQLModel + 2. await redis_manager.connect → Redis connection pool + 3. await surreal_db.connect → SurrealDB WebSocket connection + Shutdown: + 1. await redis_manager.disconnect + 2. await surreal_db.disconnect ``` The startup order matters. PostgreSQL is initialized first because the auth tables must exist before any request processing. Redis and SurrealDB follow because they depend on having a running application context. If any of these fail, the application does not start. @@ -120,10 +120,10 @@ The startup order matters. PostgreSQL is initialized first because the auth tabl | Route Prefix | Purpose | Key Endpoints | |---|---|---| -| `/auth` | WebAuthn registration + login | `register/begin`, `register/complete`, `authenticate/begin`, `authenticate/complete`, `users/search` | -| `/encryption` | X3DH key management | `prekey-bundle/{id}`, `upload-keys/{id}`, `initialize-keys/{id}`, `rotate-signed-prekey/{id}`, `opk-count/{id}` | -| `/rooms` | Chat room CRUD | Room creation, listing, participant management | -| `/ws` | WebSocket endpoint | Real-time messaging, typing, presence, receipts | +| `/auth` | WebAuthn registration + login + session | `register/begin`, `register/complete`, `authenticate/begin`, `authenticate/complete`, `me`, `logout`, `users/search` | +| `/encryption` | Public prekey bundle storage and lookup | `prekey-bundle/{id}`, `upload-keys/{id}` | +| `/rooms` | Chat room CRUD (membership-gated) | List, create, get, delete, get messages | +| `/ws` | WebSocket endpoint (session-cookie authenticated) | Real-time messaging, typing, presence, receipts | --- @@ -133,53 +133,53 @@ The startup order matters. PostgreSQL is initialized first because the auth tabl **Key Files:** `core/websocket_manager.py`, `api/websocket.py`, `services/websocket_service.py` -The `ConnectionManager` class (`websocket_manager.py:31-296`) is the center of the real-time system. It maintains three dictionaries: +The `ConnectionManager` class (`websocket_manager.py`) is the center of the real-time system. It maintains three dictionaries: ```python -self.active_connections: dict[UUID, list[WebSocket]] = {} # line 39 -self.live_query_ids: dict[UUID, str] = {} # line 40 -self.heartbeat_tasks: dict[UUID, asyncio.Task] = {} # line 41 +self.active_connections: dict[UUID, list[WebSocket]] = {} +self.live_query_ids: dict[UUID, str] = {} +self.heartbeat_tasks: dict[UUID, asyncio.Task] = {} ``` -The first maps user IDs to lists of WebSocket connections. Each user can have up to 5 simultaneous connections (`WS_MAX_CONNECTIONS_PER_USER = 5`, `config.py:141`). This supports multi-device usage without exhausting server resources. When a sixth connection attempt arrives, the manager sends an error and closes the socket (`websocket_manager.py:52-64`). +The first maps user IDs to lists of WebSocket connections. Each user can have up to 5 simultaneous connections (`WS_MAX_CONNECTIONS_PER_USER = 5`, `config.py`). This supports multi-device usage without exhausting server resources. When a sixth connection attempt arrives, the manager sends an error and closes the socket (`websocket_manager.py`). **Connection lifecycle:** ``` -connect(websocket, user_id) (websocket_manager.py:43-95) - 1. Accept WebSocket - 2. Check connection limit (max 5 per user) - 3. Add to active_connections pool - 4. Set user online via presence_service - 5. Start heartbeat loop (30s interval) - 6. Subscribe to SurrealDB live query for user's messages - 7. Return True +connect(websocket, user_id) (websocket_manager.py) + 1. Accept WebSocket + 2. Check connection limit (max 5 per user) + 3. Add to active_connections pool + 4. Set user online via presence_service + 5. Start heartbeat loop (30s interval) + 6. Subscribe to SurrealDB live query for user's messages + 7. Return True -disconnect(websocket, user_id) (websocket_manager.py:97-133) - 1. Remove WebSocket from user's connection list - 2. If last connection: - a. Set user offline via presence_service - b. Kill SurrealDB live query - c. Cancel heartbeat task +disconnect(websocket, user_id) (websocket_manager.py) + 1. Remove WebSocket from user's connection list + 2. If last connection: + a. Set user offline via presence_service + b. Kill SurrealDB live query + c. Cancel heartbeat task ``` -**Heartbeat:** The `_heartbeat_loop` method (`websocket_manager.py:177-201`) sends a ping every `WS_HEARTBEAT_INTERVAL` seconds (30s, from `config.py:140`). If the send fails, the connection is marked dead and disconnected. This catches silently dropped connections that TCP keepalive alone would miss. +**Heartbeat:** The `_heartbeat_loop` method (`websocket_manager.py`) sends a ping every `WS_HEARTBEAT_INTERVAL` seconds (30s, from `config.py`). If the send fails, the connection is marked dead and disconnected. This catches silently dropped connections that TCP keepalive alone would miss. -**Live query subscription:** When a user connects, `_subscribe_to_messages` (`websocket_manager.py:203-223`) registers a SurrealDB live query that watches for new messages where the user is the recipient. SurrealDB pushes new messages to the server in real-time through this subscription. The callback wraps updates and dispatches them through `_handle_live_message` (`websocket_manager.py:225-251`), which forwards the encrypted payload to all of the user's active WebSocket connections. +**Live query subscription:** When a user connects, `_subscribe_to_messages` (`websocket_manager.py`) registers a SurrealDB live query that watches for new messages where the user is the recipient. SurrealDB pushes new messages to the server in real-time through this subscription. The callback wraps updates and dispatches them through `_handle_live_message` (`websocket_manager.py`), which forwards the encrypted payload to all of the user's active WebSocket connections. -**Message routing:** The WebSocket endpoint (`websocket.py:25-84`) is thin. It accepts connections, delegates to `connection_manager.connect()`, and then sits in a loop reading JSON messages. Each message gets routed by `websocket_service.route_message()`, which dispatches based on the `type` field: +**Message routing:** The WebSocket endpoint (`websocket.py`) is thin. It accepts connections, delegates to `connection_manager.connect`, and then sits in a loop reading JSON messages. Each message gets routed by `websocket_service.route_message`, which dispatches based on the `type` field: ``` -route_message(websocket, user_id, message) (websocket_service.py:40-85) - ├── "encrypted_message" → handle_encrypted_message() - ├── "typing" → handle_typing_indicator() - ├── "presence" → handle_presence_update() - ├── "receipt" → handle_read_receipt() - ├── "heartbeat" → handle_heartbeat() - └── unknown → send error response +route_message(websocket, user_id, message) (backend/app/services/websocket_service.py) + ├── "encrypted_message" → handle_encrypted_message + ├── "typing" → handle_typing_indicator + ├── "presence" → handle_presence_update + ├── "receipt" → handle_read_receipt + ├── "heartbeat" → handle_heartbeat + └── unknown → send error response ``` -**Dead connection cleanup:** When `send_message()` (`websocket_manager.py:135-153`) fails to send to a connection, it collects the dead connection and calls `disconnect()` on it. This prevents stale connections from accumulating. +**Dead connection cleanup:** When `send_message` (`websocket_manager.py`) fails to send to a connection, it collects the dead connection and calls `disconnect` on it. This prevents stale connections from accumulating. --- @@ -187,7 +187,7 @@ route_message(websocket, user_id, message) (websocket_service.py:40-85) **Purpose:** Implements the Signal Protocol cryptographic primitives for end-to-end encryption. -**Key Files:** `core/encryption/x3dh_manager.py`, `core/encryption/double_ratchet.py`, `frontend/src/crypto/primitives.ts` +**Key Files:** `frontend/src/crypto/frontend/src/crypto/x3dh.ts`, `frontend/src/crypto/frontend/src/crypto/double-ratchet.ts`, `frontend/src/crypto/primitives.ts` The encryption system has two layers: @@ -195,13 +195,13 @@ The encryption system has two layers: 2. **Double Ratchet:** Uses the X3DH shared secret to derive per-message encryption keys with forward secrecy. Every message gets a unique key, and compromising one key does not reveal past or future messages. -**X3DHManager** (`x3dh_manager.py:56-353`) handles: +**X3DHManager** (`frontend/src/crypto/x3dh.ts`) handles: - Key generation for identity keys (X25519 for DH, Ed25519 for signing) -- Signed prekey generation with Ed25519 signatures (`x3dh_manager.py:116-152`) -- One-time prekey generation (`x3dh_manager.py:154-174`) -- Signed prekey verification (`x3dh_manager.py:176-206`) -- Sender-side X3DH exchange (`x3dh_manager.py:208-281`) -- Receiver-side X3DH exchange (`x3dh_manager.py:283-350`) +- Signed prekey generation with Ed25519 signatures (`frontend/src/crypto/x3dh.ts`) +- One-time prekey generation (`frontend/src/crypto/x3dh.ts`) +- Signed prekey verification (`frontend/src/crypto/x3dh.ts`) +- Sender-side X3DH exchange (`frontend/src/crypto/x3dh.ts`) +- Receiver-side X3DH exchange (`frontend/src/crypto/x3dh.ts`) The X3DH exchange on the sender side (`perform_x3dh_sender`, lines 208-281) works like this: @@ -209,84 +209,80 @@ The X3DH exchange on the sender side (`perform_x3dh_sender`, lines 208-281) work Alice wants to message Bob (who might be offline): 1. Alice fetches Bob's prekey bundle from server: - - Bob's identity key (IK_B) - - Bob's signed prekey (SPK_B) + signature - - Bob's one-time prekey (OPK_B), if available + - Bob's identity key (IK_B) + - Bob's signed prekey (SPK_B) + signature + - Bob's one-time prekey (OPK_B), if available 2. Alice verifies SPK_B signature using Bob's Ed25519 identity key 3. Alice generates ephemeral keypair (EK_A) 4. Four DH operations: - DH1 = X25519(IK_A_private, SPK_B) # Alice identity x Bob signed prekey - DH2 = X25519(EK_A_private, IK_B) # Alice ephemeral x Bob identity - DH3 = X25519(EK_A_private, SPK_B) # Alice ephemeral x Bob signed prekey - DH4 = X25519(EK_A_private, OPK_B) # Alice ephemeral x Bob one-time prekey - # (only if OPK available) + DH1 = X25519(IK_A_private, SPK_B) # Alice identity x Bob signed prekey + DH2 = X25519(EK_A_private, IK_B) # Alice ephemeral x Bob identity + DH3 = X25519(EK_A_private, SPK_B) # Alice ephemeral x Bob signed prekey + DH4 = X25519(EK_A_private, OPK_B) # Alice ephemeral x Bob one-time prekey + # (only if OPK available) 5. Concatenate: key_material = DH1 || DH2 || DH3 [|| DH4] 6. Derive shared key via HKDF-SHA256: - shared_key = HKDF( - salt = 0x00 * 32, - ikm = 0xFF * 32 || key_material, - info = "X3DH", - length = 32 - ) + shared_key = HKDF( + salt = 0x00 * 32, + ikm = 0xFF * 32 || key_material, + info = "X3DH", + length = 32 + ) 7. Return: shared_key, associated_data (IK_A_pub || IK_B_pub), EK_A_pub ``` The `0xFF * 32` prefix before the key material is a domain separator. This matches the Signal specification and prevents potential cross-protocol attacks. -**DoubleRatchet** (`double_ratchet.py:64-419`) handles: -- Sender initialization from X3DH shared secret (`double_ratchet.py:279-302`) -- Receiver initialization (`double_ratchet.py:304-321`) -- Message encryption with chain key advancement (`double_ratchet.py:323-362`) -- Message decryption with out-of-order support (`double_ratchet.py:364-416`) -- DH ratchet steps for forward secrecy (`double_ratchet.py:155-213`) -- Skipped message key storage for out-of-order delivery (`double_ratchet.py:215-258`) +**DoubleRatchet** (`frontend/src/crypto/double-ratchet.ts`) handles: +- Sender initialization from X3DH shared secret (`frontend/src/crypto/double-ratchet.ts`) +- Receiver initialization (`frontend/src/crypto/double-ratchet.ts`) +- Message encryption with chain key advancement (`frontend/src/crypto/double-ratchet.ts`) +- Message decryption with out-of-order support (`frontend/src/crypto/double-ratchet.ts`) +- DH ratchet steps for forward secrecy (`frontend/src/crypto/double-ratchet.ts`) +- Skipped message key storage for out-of-order delivery (`frontend/src/crypto/double-ratchet.ts`) The ratchet uses two KDF chains: ``` Root Key Chain: - _kdf_rk(root_key, dh_output) → (new_root_key, new_chain_key) - Uses HKDF-SHA256 with root_key as salt (double_ratchet.py:79-94) + _kdf_rk(root_key, dh_output) → (new_root_key, new_chain_key) + Uses HKDF-SHA256 with root_key as salt (frontend/src/crypto/double-ratchet.ts) Symmetric Key Chain: - _kdf_ck(chain_key) → (next_chain_key, message_key) - Uses HMAC-SHA256 with 0x01 for chain key, 0x02 for message key - (double_ratchet.py:96-109) + _kdf_ck(chain_key) → (next_chain_key, message_key) + Uses HMAC-SHA256 with 0x01 for chain key, 0x02 for message key + (frontend/src/crypto/double-ratchet.ts) ``` -Each message is encrypted with AES-256-GCM using a 12-byte random nonce (`double_ratchet.py:111-130`). The message key is derived from the sending chain and used exactly once. After encryption, the sending chain advances, and the old message key is discarded. +Each message is encrypted with AES-256-GCM using a 12-byte random nonce (`frontend/src/crypto/double-ratchet.ts`). The message key is derived from the sending chain and used exactly once. After encryption, the sending chain advances, and the old message key is discarded. **Security limits from `config.py`:** -- `MAX_SKIP_MESSAGE_KEYS = 1000` (line 73): Maximum messages that can arrive out of order before the protocol rejects them -- `MAX_CACHED_MESSAGE_KEYS = 2000` (line 74): Maximum stored skipped keys before eviction -- `AES_GCM_NONCE_SIZE = 12` (line 69): 96-bit nonces for AES-GCM -- `HKDF_OUTPUT_SIZE = 32` (line 70): 256-bit derived keys +- `MAX_SKIP_MESSAGE_KEYS = 1000` : Maximum messages that can arrive out of order before the protocol rejects them +- `MAX_CACHED_MESSAGE_KEYS = 2000` : Maximum stored skipped keys before eviction +- `AES_GCM_NONCE_SIZE = 12` : 96-bit nonces for AES-GCM +- `HKDF_OUTPUT_SIZE = 32` : 256-bit derived keys -**Client-side crypto:** The frontend `primitives.ts` (lines 1-397) mirrors the backend crypto using the WebCrypto API. It provides: -- `generateX25519KeyPair()` (line 15) -- `x25519DeriveSharedSecret()` (line 26) -- `generateEd25519KeyPair()` (line 89) -- `ed25519Sign()` / `ed25519Verify()` (lines 99, 113) -- `hkdfDerive()` / `hkdfDeriveKey()` (lines 166, 194) -- `aesGcmEncrypt()` / `aesGcmDecrypt()` (lines 224, 261) -- `hmacSha256()` / `hmacSha256Verify()` (lines 310, 329) -- `constantTimeEqual()` (line 388): Constant-time comparison to prevent timing side channels +**Client-side crypto:** The frontend `primitives.ts` mirrors the backend crypto using the WebCrypto API. It provides: +- `generateX25519KeyPair` +- `x25519DeriveSharedSecret` +- `generateEd25519KeyPair` +- `ed25519Sign` / `ed25519Verify` (lines 99, 113) +- `hkdfDerive` / `hkdfDeriveKey` (lines 166, 194) +- `aesGcmEncrypt` / `aesGcmDecrypt` (lines 224, 261) +- `hmacSha256` / `hmacSha256Verify` (lines 310, 329) +- `constantTimeEqual` : Constant-time comparison to prevent timing side channels The client also has `crypto-service.ts`, `double-ratchet.ts`, `x3dh.ts`, `key-store.ts`, and `message-store.ts` which orchestrate these primitives into the full protocol flows. -**Server-side vs client-side encryption paths:** +**One encryption path: client-side, server is opaque.** -The `MessageService` (`message_service.py`) has two paths: -- `store_encrypted_message()` (lines 269-314): Client-side passthrough. The server receives ciphertext, nonce, and header as strings and stores them as-is in SurrealDB. No decryption or re-encryption on the server. -- `send_encrypted_message()` (lines 316-402): Server-side encryption. Marked `[DEPRECATED]` in the docstring (line 325). This path loads the ratchet state from PostgreSQL, encrypts on the server, and stores in SurrealDB. It exists for backwards compatibility during migration to full client-side encryption. - -The client-side path is the intended production path. In this path, the server literally cannot read messages because it never has the keys. +`MessageService` (`backend/app/services/message_service.py`) exposes a single method, `store_encrypted_message`, which receives `ciphertext`, `nonce`, and `header` as opaque strings and writes them straight into SurrealDB. The server has no decryption helper and no ratchet state — it literally cannot read messages because it never has the keys. --- @@ -296,66 +292,66 @@ The client-side path is the intended production path. In this path, the server l **Key Files:** `core/passkey/passkey_manager.py`, `api/auth.py`, `services/auth_service.py`, `core/redis_manager.py` -The `PasskeyManager` (`passkey_manager.py:43-210`) wraps the `py_webauthn` library. It is configured with the Relying Party (RP) settings from `config.py`: +The `PasskeyManager` (`backend/app/core/passkey/passkey_manager.py`) wraps the `py_webauthn` library. It is configured with the Relying Party (RP) settings from `config.py`: ```python -self.rp_id = settings.RP_ID # e.g., "localhost" or "chat.example.com" -self.rp_name = settings.RP_NAME # "Encrypted P2P Chat" +self.rp_id = settings.RP_ID # e.g., "localhost" or "chat.example.com" +self.rp_name = settings.RP_NAME # "Encrypted P2P Chat" self.rp_origin = settings.RP_ORIGIN # "https://chat.example.com" ``` **Registration flow:** ``` -Client Server Redis - │ │ │ - │ POST /auth/register/begin│ │ - │ { username, display_name }│ │ - │ ─────────────────────────►│ │ - │ │ │ - │ PasskeyManager.generate_registration_options() - │ (passkey_manager.py:55-94) │ - │ │ │ - │ │ SET webauthn:reg_challenge:{username} - │ │ challenge_bytes, TTL=600s│ - │ │ ──────────────────────────► - │ │ │ - │ ◄─── registration options (publicKey config) ────────│ - │ │ │ - │ Browser WebAuthn API │ │ - │ navigator.credentials │ │ - │ .create(options) │ │ - │ User touches │ │ - │ authenticator │ │ - │ │ │ - │ POST /auth/register/complete │ - │ { credential, username } │ │ - │ ─────────────────────────►│ │ - │ │ │ - │ │ GET+DEL webauthn:reg_challenge:{username} - │ │ ──────────────────────────► - │ │ ◄── challenge_bytes ──────│ - │ │ │ - │ PasskeyManager.verify_registration() │ - │ (passkey_manager.py:96-130) │ - │ │ │ - │ Create User in PostgreSQL │ - │ Store Credential in PostgreSQL │ - │ Initialize X3DH keys │ - │ (prekey_service.py:152-219) │ - │ │ │ - │ ◄─── UserResponse (id, username, etc.) ──────────────│ +Client Server Redis + │ │ │ + │ POST /auth/register/begin│ │ + │ { username, display_name }│ │ + │ ─────────────────────────►│ │ + │ │ │ + │ PasskeyManager.generate_registration_options + │ (backend/app/core/passkey/passkey_manager.py) │ + │ │ │ + │ │ SET webauthn:reg_challenge:{username} + │ │ challenge_bytes, TTL=600s│ + │ │ ──────────────────────────► + │ │ │ + │ ◄─── registration options (publicKey config) ────────│ + │ │ │ + │ Browser WebAuthn API │ │ + │ navigator.credentials │ │ + │ .create(options) │ │ + │ User touches │ │ + │ authenticator │ │ + │ │ │ + │ POST /auth/register/complete │ + │ { credential, username } │ │ + │ ─────────────────────────►│ │ + │ │ │ + │ │ GET+DEL webauthn:reg_challenge:{username} + │ │ ──────────────────────────► + │ │ ◄── challenge_bytes ──────│ + │ │ │ + │ PasskeyManager.verify_registration │ + │ (backend/app/core/passkey/passkey_manager.py) │ + │ │ │ + │ Create User in PostgreSQL │ + │ Store Credential in PostgreSQL │ + │ Initialize X3DH keys │ + │ (backend/app/services/prekey_service.py) │ + │ │ │ + │ ◄─── UserResponse (id, username, etc.) ──────────────│ ``` **Authentication flow:** -The authentication flow is similar but uses `generate_authentication_options` and `verify_authentication`. A critical detail is the signature counter check (`passkey_manager.py:184-193`): if the new counter is not greater than the stored counter, it raises a `ValueError` indicating a potentially cloned authenticator. This is the WebAuthn clone detection mechanism. +The authentication flow is similar but uses `generate_authentication_options` and `verify_authentication`. A critical detail is the signature counter check (`backend/app/core/passkey/passkey_manager.py`): if the new counter is not greater than the stored counter, it raises a `ValueError` indicating a potentially cloned authenticator. This is the WebAuthn clone detection mechanism. -**Challenge storage:** Redis stores challenges with a 600-second TTL (`WEBAUTHN_CHALLENGE_TTL_SECONDS = 600`, `config.py:84`). The `get_registration_challenge` and `get_authentication_challenge` methods use Redis pipelines to atomically GET and DELETE the challenge (`redis_manager.py:86-95`). This ensures each challenge is used exactly once. +**Challenge storage:** Redis stores challenges with a 600-second TTL (`WEBAUTHN_CHALLENGE_TTL_SECONDS = 600`, `config.py`). The `get_registration_challenge` and `get_authentication_challenge` methods use Redis pipelines to atomically GET and DELETE the challenge (`redis_manager.py`). This ensures each challenge is used exactly once. -The challenge itself is 32 random bytes (`WEBAUTHN_CHALLENGE_BYTES = 32`, `config.py:85`), stored as hex in Redis. +The challenge itself is 32 random bytes (`WEBAUTHN_CHALLENGE_BYTES = 32`, `config.py`), stored as hex in Redis. -**Auth endpoints** (`auth.py:31-103`): +**Auth endpoints** (`auth.py`): | Endpoint | Method | Status | Description | |---|---|---|---| @@ -373,23 +369,23 @@ The challenge itself is 32 random bytes (`WEBAUTHN_CHALLENGE_BYTES = 32`, `confi **PostgreSQL via SQLModel/SQLAlchemy async:** Handles all relational data with ACID guarantees. -The engine is configured in `models/Base.py:38-44`: +The engine is configured in `models/Base.py`: ```python engine = create_async_engine( - str(settings.DATABASE_URL), # postgresql+asyncpg://... - echo = settings.DEBUG, # SQL logging in development - pool_size = settings.DB_POOL_SIZE, # 20 (config.py:118) - max_overflow = settings.DB_MAX_OVERFLOW, # 40 (config.py:119) - pool_pre_ping = True, # Detect stale connections + str(settings.DATABASE_URL), # postgresql+asyncpg://... + echo = settings.DEBUG, # SQL logging in development + pool_size = settings.DB_POOL_SIZE, # 20 (config.py) + max_overflow = settings.DB_MAX_OVERFLOW, # 40 (config.py) + pool_pre_ping = True, # Detect stale connections ) ``` -The session factory (`Base.py:47-51`) creates `AsyncSession` instances with `expire_on_commit=False` so that objects remain usable after commit without requiring a refresh. +The session factory (`Base.py`) creates `AsyncSession` instances with `expire_on_commit=False` so that objects remain usable after commit without requiring a refresh. -**SurrealDB via AsyncSurreal:** Handles real-time messaging data. The `SurrealDBManager` (`surreal_manager.py:28-428`) connects over WebSocket to SurrealDB and provides methods for message CRUD, room management, presence tracking, and live query subscriptions. The live query feature is the primary reason SurrealDB was chosen: it pushes new records to subscribers in real-time, eliminating the need for polling. +**SurrealDB via AsyncSurreal:** Handles real-time messaging data. The `SurrealDBManager` (`surreal_manager.py`) connects over WebSocket to SurrealDB and provides methods for message CRUD, room management, presence tracking, and live query subscriptions. The live query feature is the primary reason SurrealDB was chosen: it pushes new records to subscribers in real-time, eliminating the need for polling. -**Redis via redis.asyncio:** Handles ephemeral data. The `RedisManager` (`redis_manager.py:19-174`) uses a connection pool of 50 connections (`redis_manager.py:39`) and stores challenges as hex-encoded bytes with TTL-based expiry. +**Redis via redis.asyncio:** Handles ephemeral data. The `RedisManager` (`redis_manager.py`) uses a connection pool of 50 connections (`redis_manager.py`) and stores challenges as hex-encoded bytes with TTL-based expiry. --- @@ -400,124 +396,124 @@ The session factory (`Base.py:47-51`) creates `AsyncSession` instances with `exp This is the most important flow in the system. Here is what happens step by step when Alice sends a message to Bob, assuming they already have an established Double Ratchet session: ``` -Alice's Browser Server Bob's Browser - │ │ │ - ┌────┴────────────────┐ │ │ - │ 1. User types msg │ │ │ - │ in ChatInput.tsx │ │ │ - └────┬────────────────┘ │ │ - │ │ │ - ┌────┴────────────────┐ │ │ - │ 2. crypto-service.ts│ │ │ - │ encrypts: │ │ │ - │ a. Advance send │ │ │ - │ chain via │ │ │ - │ HMAC-SHA256 │ │ │ - │ b. Derive unique │ │ │ - │ message key │ │ │ - │ c. AES-256-GCM │ │ │ - │ encrypt with │ │ │ - │ random nonce │ │ │ - │ d. Build header: │ │ │ - │ {dh_pub_key, │ │ │ - │ msg_number, │ │ │ - │ prev_chain} │ │ │ - └────┬────────────────┘ │ │ - │ │ │ - │ 3. WebSocket send: │ │ - │ { │ │ - │ type: "encrypted_message", │ - │ recipient_id: bob_uuid, │ │ - │ room_id: room_uuid, │ │ - │ ciphertext: "base64...",│ │ - │ nonce: "base64...", │ │ - │ header: "{...json...}", │ │ - │ temp_id: "client_123" │ │ - │ } │ │ - │ ───────────────────────────► │ - │ │ │ - │ ┌────────────┴───────────────┐ │ - │ │ 4. websocket.py:46-54 │ │ - │ │ receives JSON, parses │ │ - │ │ routes via │ │ - │ │ websocket_service │ │ - │ └────────────┬───────────────┘ │ - │ │ │ - │ ┌────────────┴───────────────┐ │ - │ │ 5. websocket_service.py: │ │ - │ │ handle_encrypted_message│ │ - │ │ (lines 87-179) │ │ - │ │ Extracts fields, │ │ - │ │ opens DB session │ │ - │ └────────────┬───────────────┘ │ - │ │ │ - │ ┌────────────┴───────────────┐ │ - │ │ 6. message_service.py: │ │ - │ │ store_encrypted_message │ │ - │ │ (lines 269-314) │ │ - │ │ Looks up sender user │ │ - │ │ for username. │ │ - │ │ Stores ciphertext, │ │ - │ │ nonce, header AS-IS in │ │ - │ │ SurrealDB. │ │ - │ │ NO DECRYPTION. │ │ - │ └────────────┬───────────────┘ │ - │ │ │ - │ ┌────────────┴───────────────┐ │ - │ │ 7. SurrealDB live query │ │ - │ │ fires for Bob because │ │ - │ │ recipient_id matches │ │ - │ └────────────┬───────────────┘ │ - │ │ │ - │ ┌────────────┴───────────────┐ │ - │ │ 8. websocket_manager.py: │ │ - │ │ _handle_live_message │ │ - │ │ (lines 225-251) │ │ - │ │ Wraps as EncryptedMsgWS │ │ - │ └────────────┬───────────────┘ │ - │ │ │ - │ ┌────────────┴───────────────┐ │ - │ │ 9. send_message(bob_uuid) │ │ - │ │ (lines 135-153) │ │ - │ │ Sends to ALL of Bob's │ │ - │ │ active WebSockets │ │ - │ └────────────┬───────────────┘ │ - │ │ ─────────────────────────────► - │ │ │ - │ │ ┌────────────────┴──┐ - │ │ │ 10. Bob's crypto- │ - │ │ │ service.ts │ - │ │ │ decrypts: │ - │ │ │ a. Check for │ - │ │ │ skipped │ - │ │ │ msg keys │ - │ │ │ b. If new DH │ - │ │ │ pub key, │ - │ │ │ DH ratchet │ - │ │ │ step │ - │ │ │ c. Advance │ - │ │ │ recv chain │ - │ │ │ d. Derive msg │ - │ │ │ key │ - │ │ │ e. AES-256-GCM│ - │ │ │ decrypt │ - │ │ └────────┬─────────┘ - │ │ │ - │ │ ┌────────┴─────────┐ - │ │ │ 11. Plaintext │ - │ │ │ rendered in │ - │ │ │ MessageList │ - │ │ └──────────────────┘ - │ │ │ - │ ◄── confirmation ─────────│ │ - │ { type: "message_sent", │ │ - │ temp_id: "client_123", │ │ - │ status: "sent" } │ │ +Alice's Browser Server Bob's Browser + │ │ │ + ┌────┴────────────────┐ │ │ + │ 1. User types msg │ │ │ + │ in ChatInput.tsx │ │ │ + └────┬────────────────┘ │ │ + │ │ │ + ┌────┴────────────────┐ │ │ + │ 2. crypto-service.ts│ │ │ + │ encrypts: │ │ │ + │ a. Advance send │ │ │ + │ chain via │ │ │ + │ HMAC-SHA256 │ │ │ + │ b. Derive unique │ │ │ + │ message key │ │ │ + │ c. AES-256-GCM │ │ │ + │ encrypt with │ │ │ + │ random nonce │ │ │ + │ d. Build header: │ │ │ + │ {dh_pub_key, │ │ │ + │ msg_number, │ │ │ + │ prev_chain} │ │ │ + └────┬────────────────┘ │ │ + │ │ │ + │ 3. WebSocket send: │ │ + │ { │ │ + │ type: "encrypted_message", │ + │ recipient_id: bob_uuid, │ │ + │ room_id: room_uuid, │ │ + │ ciphertext: "base64...",│ │ + │ nonce: "base64...", │ │ + │ header: "{...json...}", │ │ + │ temp_id: "client_123" │ │ + │ } │ │ + │ ───────────────────────────► │ + │ │ │ + │ ┌────────────┴───────────────┐ │ + │ │ 4. websocket.py │ │ + │ │ receives JSON, parses │ │ + │ │ routes via │ │ + │ │ websocket_service │ │ + │ └────────────┬───────────────┘ │ + │ │ │ + │ ┌────────────┴───────────────┐ │ + │ │ 5. websocket_service.py: │ │ + │ │ handle_encrypted_message│ │ + │ │ │ │ + │ │ Extracts fields, │ │ + │ │ opens DB session │ │ + │ └────────────┬───────────────┘ │ + │ │ │ + │ ┌────────────┴───────────────┐ │ + │ │ 6. message_service.py: │ │ + │ │ store_encrypted_message │ │ + │ │ │ │ + │ │ Looks up sender user │ │ + │ │ for username. │ │ + │ │ Stores ciphertext, │ │ + │ │ nonce, header AS-IS in │ │ + │ │ SurrealDB. │ │ + │ │ NO DECRYPTION. │ │ + │ └────────────┬───────────────┘ │ + │ │ │ + │ ┌────────────┴───────────────┐ │ + │ │ 7. SurrealDB live query │ │ + │ │ fires for Bob because │ │ + │ │ recipient_id matches │ │ + │ └────────────┬───────────────┘ │ + │ │ │ + │ ┌────────────┴───────────────┐ │ + │ │ 8. websocket_manager.py: │ │ + │ │ _handle_live_message │ │ + │ │ │ │ + │ │ Wraps as EncryptedMsgWS │ │ + │ └────────────┬───────────────┘ │ + │ │ │ + │ ┌────────────┴───────────────┐ │ + │ │ 9. send_message(bob_uuid) │ │ + │ │ │ │ + │ │ Sends to ALL of Bob's │ │ + │ │ active WebSockets │ │ + │ └────────────┬───────────────┘ │ + │ │ ─────────────────────────────► + │ │ │ + │ │ ┌────────────────┴──┐ + │ │ │ 10. Bob's crypto- │ + │ │ │ service.ts │ + │ │ │ decrypts: │ + │ │ │ a. Check for │ + │ │ │ skipped │ + │ │ │ msg keys │ + │ │ │ b. If new DH │ + │ │ │ pub key, │ + │ │ │ DH ratchet │ + │ │ │ step │ + │ │ │ c. Advance │ + │ │ │ recv chain │ + │ │ │ d. Derive msg │ + │ │ │ key │ + │ │ │ e. AES-256-GCM│ + │ │ │ decrypt │ + │ │ └────────┬─────────┘ + │ │ │ + │ │ ┌────────┴─────────┐ + │ │ │ 11. Plaintext │ + │ │ │ rendered in │ + │ │ │ MessageList │ + │ │ └──────────────────┘ + │ │ │ + │ ◄── confirmation ─────────│ │ + │ { type: "message_sent", │ │ + │ temp_id: "client_123", │ │ + │ status: "sent" } │ │ ``` -The server never sees the plaintext. It acts purely as a relay that stores and forwards ciphertext blobs. The `store_encrypted_message` method (`message_service.py:269-314`) literally just wraps the received fields into a dict and calls `surreal_db.create_message()`. +The server never sees the plaintext. It acts purely as a relay that stores and forwards ciphertext blobs. The `store_encrypted_message` method (`backend/app/services/message_service.py`) literally just wraps the received fields into a dict and calls `surreal_db.create_message`. -The confirmation message sent back to Alice (`websocket_service.py:148-159`) includes the `temp_id` so the client can match it to the optimistically rendered message in the UI. +The confirmation message sent back to Alice (`backend/app/services/websocket_service.py`) includes the `temp_id` so the client can match it to the optimistically rendered message in the UI. --- @@ -527,45 +523,45 @@ This flow happens once per user. ``` Step 1: POST /auth/register/begin - auth_service.py:331-374 - → PasskeyManager generates challenge - → Challenge stored in Redis with 600s TTL - → Registration options returned to client + backend/app/services/auth_service.py + → PasskeyManager generates challenge + → Challenge stored in Redis with 600s TTL + → Registration options returned to client Step 2: Browser WebAuthn API (navigator.credentials.create) - → User interacts with authenticator (Touch ID, YubiKey, etc.) - → Browser creates credential bound to RP origin + → User interacts with authenticator (Touch ID, YubiKey, etc.) + → Browser creates credential bound to RP origin Step 3: POST /auth/register/complete - auth_service.py:376-437 - → Challenge retrieved from Redis (GET+DEL atomic) - → PasskeyManager verifies credential against challenge - → User record created in PostgreSQL - → Credential record created in PostgreSQL - → prekey_service.initialize_user_keys() called - (prekey_service.py:152-219) + backend/app/services/auth_service.py + → Challenge retrieved from Redis (GET+DEL atomic) + → PasskeyManager verifies credential against challenge + → User record created in PostgreSQL + → Credential record created in PostgreSQL + → prekey_service.initialize_user_keys called + (backend/app/services/prekey_service.py) -Step 4: Server-side key initialization (prekey_service.py:152-219) - → Generate X25519 identity keypair (IK) - → Generate Ed25519 signing keypair - → Store both public+private in PostgreSQL identity_keys table - → Generate signed prekey (SPK) signed with Ed25519 IK - → Generate 100 one-time prekeys (OPKs) - → Store all in PostgreSQL +Step 4: Server-side key initialization (backend/app/services/prekey_service.py) + → Generate X25519 identity keypair (IK) + → Generate Ed25519 signing keypair + → Store both public+private in PostgreSQL identity_keys table + → Generate signed prekey (SPK) signed with Ed25519 IK + → Generate 100 one-time prekeys (OPKs) + → Store all in PostgreSQL Step 5: (Client-side, post-registration) - Client generates its own X3DH keys using WebCrypto: - → X25519 identity keypair - → Ed25519 signing keypair - → Signed prekey with signature - → 100 one-time prekeys + Client generates its own X3DH keys using WebCrypto: + → X25519 identity keypair + → Ed25519 signing keypair + → Signed prekey with signature + → 100 one-time prekeys Step 6: POST /encryption/upload-keys/{user_id} - encryption.py:72-95 - → prekey_service.store_client_keys() - (prekey_service.py:45-150) - → Only PUBLIC keys stored on server - → Private keys remain in browser IndexedDB + backend/app/api/encryption.py + → prekey_service.store_client_keys + (backend/app/services/prekey_service.py) + → Only PUBLIC keys stored on server + → Private keys remain in browser IndexedDB ``` Note there is a dual path here. Step 4 is the server-side key generation (used as a fallback and for backwards compatibility). Step 5-6 is the client-side key generation path (the preferred production path). In the client-side path, the server never sees private keys. @@ -578,45 +574,45 @@ When Alice wants to message Bob for the first time: ``` Step 1: Alice's client requests Bob's prekey bundle - GET /encryption/prekey-bundle/{bob_id} - encryption.py:32-51 + GET /encryption/prekey-bundle/{bob_id} + backend/app/api/encryption.py Step 2: prekey_service.get_prekey_bundle(session, bob_id) - prekey_service.py:293-361 - → Fetch Bob's identity key (IK) - → Fetch Bob's active signed prekey (SPK) - → If no active SPK, auto-rotate (prekey_service.py:321) - → Fetch one unused one-time prekey (OPK) - → Mark OPK as used (single-use guarantee, line 332) - → If unused OPK count < 20, auto-replenish 100 more - (encryption.py:47-49) - → Return PreKeyBundle{IK, IK_ed25519, SPK, SPK_sig, OPK} + backend/app/services/prekey_service.py + → Fetch Bob's identity key (IK) + → Fetch Bob's active signed prekey (SPK) + → If no active SPK, auto-rotate (backend/app/services/prekey_service.py) + → Fetch one unused one-time prekey (OPK) + → Mark OPK as used (single-use guarantee,) + → If unused OPK count < 20, auto-replenish 100 more + (backend/app/api/encryption.py) + → Return PreKeyBundle{IK, IK_ed25519, SPK, SPK_sig, OPK} Step 3: Alice performs X3DH sender-side locally - Using the prekey bundle and her own identity key: - a. Verify SPK signature with Bob's Ed25519 IK - b. Generate ephemeral keypair (EK) - c. Compute DH1..DH4 - d. Derive shared_key via HKDF + Using the prekey bundle and her own identity key: + a. Verify SPK signature with Bob's Ed25519 IK + b. Generate ephemeral keypair (EK) + c. Compute DH1..DH4 + d. Derive shared_key via HKDF Step 4: Initialize Double Ratchet with shared_key - Alice's client calls double_ratchet.initialize_sender() - with the shared key and Bob's SPK as the initial peer key + Alice's client calls double_ratchet.initialize_sender + with the shared key and Bob's SPK as the initial peer key Step 5: First message includes X3DH header - The header contains Alice's ephemeral public key and - identity key reference so Bob can derive the same - shared secret when he comes online + The header contains Alice's ephemeral public key and + identity key reference so Bob can derive the same + shared secret when he comes online Step 6: Bob receives the message - Bob's client uses the X3DH header + his own private keys - to perform the receiver-side X3DH exchange - (x3dh_manager.py:283-350) - Both parties now share the same root key for the - Double Ratchet + Bob's client uses the X3DH header + his own private keys + to perform the receiver-side X3DH exchange + (frontend/src/crypto/x3dh.ts) + Both parties now share the same root key for the + Double Ratchet ``` -The OPK single-use guarantee is enforced at the database level. When `get_prekey_bundle` fetches an OPK, it immediately marks `is_used = True` and commits (`prekey_service.py:332-345`). If someone else requests the same OPK concurrently, they will get a different one or none. +The OPK single-use guarantee is enforced at the database level. When `get_prekey_bundle` fetches an OPK, it immediately marks `is_used = True` and commits (`backend/app/services/prekey_service.py`). If someone else requests the same OPK concurrently, they will get a different one or none. --- @@ -624,9 +620,9 @@ The OPK single-use guarantee is enforced at the database level. When `get_prekey ### Application Factory Pattern -**Where:** `factory.py:63-115` +**Where:** `factory.py` -**Why:** The `create_app()` function builds and returns a fully configured FastAPI instance. This separates app creation from app execution (`main.py` just calls `create_app()`). The practical benefit is testability: you can call `create_app()` with different configurations in tests without starting a server. The lifespan manager (`factory.py:39-61`) ensures all databases are connected before the first request and properly disconnected on shutdown. +**Why:** The `create_app` function builds and returns a fully configured FastAPI instance. This separates app creation from app execution (`main.py` just calls `create_app`). The practical benefit is testability: you can call `create_app` with different configurations in tests without starting a server. The lifespan manager (`factory.py`) ensures all databases are connected before the first request and properly disconnected on shutdown. ### Service Layer Pattern @@ -635,42 +631,40 @@ The OPK single-use guarantee is enforced at the database level. When `get_prekey **Why:** Business logic lives in service classes, not in API endpoint functions. The API layer (`api/`) is thin: it handles request validation and response formatting, then delegates to services. Services are stateless singletons instantiated at module level: ```python -message_service = MessageService() # message_service.py:469 -prekey_service = PrekeyService() # prekey_service.py:468 -auth_service = AuthService() # auth_service.py:601 -websocket_service = WebSocketService() # websocket_service.py:324 +message_service = MessageService # backend/app/services/message_service.py +prekey_service = PrekeyService # backend/app/services/prekey_service.py +auth_service = AuthService # backend/app/services/auth_service.py +websocket_service = WebSocketService # backend/app/services/websocket_service.py ``` This means any endpoint can import and call any service without worrying about instantiation or dependency injection. The tradeoff is that services cannot be easily swapped at runtime, but for this application that is not needed. ### Connection Pool Pattern -**Where:** `websocket_manager.py:31-42` +**Where:** `websocket_manager.py` -**Why:** The `ConnectionManager` maps user IDs to lists of WebSocket connections. This supports multiple simultaneous devices per user (up to 5, enforced at `websocket_manager.py:52`). When a message needs to be delivered, `send_message()` iterates over all connections for that user. Dead connections are detected during send attempts and cleaned up immediately. +**Why:** The `ConnectionManager` maps user IDs to lists of WebSocket connections. This supports multiple simultaneous devices per user (up to 5, enforced at `websocket_manager.py`). When a message needs to be delivered, `send_message` iterates over all connections for that user. Dead connections are detected during send attempts and cleaned up immediately. ### Observer Pattern (Live Queries) -**Where:** `websocket_manager.py:203-224`, `surreal_manager.py:341-359` +**Where:** `websocket_manager.py`, `surreal_manager.py` **Why:** SurrealDB live queries implement a push-based notification system. When a new message is created in SurrealDB with `recipient_id = bob`, SurrealDB pushes that record to the server through the live query callback. The server then forwards it to Bob's WebSocket connections. This eliminates polling entirely. The alternative would be the server polling SurrealDB for new messages, which would add latency proportional to the polling interval and waste resources when no messages are pending. -The subscription is per-user, not per-room. `live_messages_for_user()` (`surreal_manager.py:341-359`) watches `WHERE recipient_id = '{user_id}'`, so the server receives all messages destined for that user regardless of room. +The subscription is per-user, not per-room. `live_messages_for_user` (`surreal_manager.py`) watches `WHERE recipient_id = '{user_id}'`, so the server receives all messages destined for that user regardless of room. ### Singleton Pattern **Where:** Module-level instances at the bottom of each manager file: ```python -x3dh_manager = X3DHManager() # x3dh_manager.py:353 -double_ratchet = DoubleRatchet() # double_ratchet.py:419 -passkey_manager = PasskeyManager() # passkey_manager.py:210 -connection_manager = ConnectionManager() # websocket_manager.py:296 -surreal_db = SurrealDBManager() # surreal_manager.py:428 -redis_manager = RedisManager() # redis_manager.py:174 +passkey_manager = PasskeyManager # backend/app/core/passkey/passkey_manager.py +connection_manager = ConnectionManager # backend/app/core/websocket_manager.py +surreal_db = SurrealDBManager # backend/app/core/surreal_manager.py +redis_manager = RedisManager # backend/app/core/redis_manager.py ``` -**Why:** These managers hold no per-request state. `X3DHManager` and `DoubleRatchet` are pure functions wrapped in a class (they take state as arguments and return results). `PasskeyManager` holds only the RP configuration. The connection managers (`surreal_db`, `redis_manager`, `connection_manager`) hold shared connection pools. A single instance per process is the correct model. +**Why:** These managers either hold no per-request state (`PasskeyManager` holds only the RP configuration) or manage shared connection pools (`surreal_db`, `redis_manager`, `connection_manager`). A single instance per process is the correct model. The X3DH and Double Ratchet implementations live in the browser as plain TypeScript modules — there is no server-side counterpart that needs a singleton. --- @@ -678,60 +672,61 @@ redis_manager = RedisManager() # redis_manager.py:174 ``` ┌─────────────────────────────────────────────────────────────────┐ -│ API Layer (api/) │ -│ ┌───────────┐ ┌──────────────┐ ┌─────────┐ ┌──────────────┐ │ -│ │ auth.py │ │ encryption.py│ │ rooms.py│ │ websocket.py │ │ -│ │ lines │ │ lines 1-127 │ │ │ │ lines 1-85 │ │ -│ │ 1-104 │ │ │ │ │ │ │ │ -│ └───────────┘ └──────────────┘ └─────────┘ └──────────────┘ │ -│ HTTP/WS endpoints. Request validation. Response formatting. │ -│ Thin wrappers that delegate to services. │ +│ API Layer (api/) │ +│ ┌───────────┐ ┌──────────────┐ ┌─────────┐ ┌──────────────┐ │ +│ │ auth.py │ │ encryption.py│ │ rooms.py│ │ websocket.py │ │ +│ │ lines │ │ lines 1-127 │ │ │ │ lines 1-85 │ │ +│ │ 1-104 │ │ │ │ │ │ │ │ +│ └───────────┘ └──────────────┘ └─────────┘ └──────────────┘ │ +│ HTTP/WS endpoints. Request validation. Response formatting. │ +│ Thin wrappers that delegate to services. │ ├─────────────────────────────────────────────────────────────────┤ -│ Service Layer (services/) │ -│ ┌──────────────┐ ┌───────────────┐ ┌─────────────────────┐ │ -│ │ auth_service │ │ prekey_service│ │ message_service │ │ -│ │ lines 1-601 │ │ lines 1-468 │ │ lines 1-469 │ │ -│ └──────────────┘ └───────────────┘ └─────────────────────┘ │ -│ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ presence_service │ │ websocket_service│ │ -│ └──────────────────┘ └──────────────────┘ │ -│ Business logic. Orchestration. Error handling. │ -│ Stateless singletons. Import Core + Models. │ +│ Service Layer (services/) │ +│ ┌──────────────┐ ┌───────────────┐ ┌─────────────────────┐ │ +│ │ auth_service │ │ prekey_service│ │ message_service │ │ +│ │ lines 1-601 │ │ lines 1-468 │ │ lines 1-469 │ │ +│ └──────────────┘ └───────────────┘ └─────────────────────┘ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ presence_service │ │ websocket_service│ │ +│ └──────────────────┘ └──────────────────┘ │ +│ Business logic. Orchestration. Error handling. │ +│ Stateless singletons. Import Core + Models. │ ├─────────────────────────────────────────────────────────────────┤ -│ Core Layer (core/) │ -│ ┌──────────────┐ ┌───────────────┐ ┌─────────────────────┐ │ -│ │ x3dh_manager │ │ double_ratchet│ │ passkey_manager │ │ -│ │ lines 1-353 │ │ lines 1-419 │ │ lines 1-210 │ │ -│ └──────────────┘ └───────────────┘ └─────────────────────┘ │ -│ ┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐ │ -│ │ websocket_manager│ │ surreal_mgr │ │ redis_manager │ │ -│ │ lines 1-296 │ │ lines 1-428 │ │ lines 1-174 │ │ -│ └──────────────────┘ └──────────────┘ └──────────────────┘ │ -│ ┌──────────────────┐ ┌──────────────────────────────────┐ │ -│ │ exceptions.py │ │ exception_handlers.py │ │ -│ └──────────────────┘ └──────────────────────────────────┘ │ -│ Protocol implementations. Database clients. WebSocket pool. │ -│ No imports from API or Services. │ +│ Core Layer (core/) │ +│ ┌─────────────────────┐ ┌─────────────────┐ │ +│ │ passkey_manager │ │ dependencies │ │ +│ │ (WebAuthn ceremony) │ │ (current_user) │ │ +│ └─────────────────────┘ └─────────────────┘ │ +│ ┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ websocket_manager│ │ surreal_mgr │ │ redis_manager │ │ +│ │ (connection pool)│ │ (live query) │ │ (sessions+chal.) │ │ +│ └──────────────────┘ └──────────────┘ └──────────────────┘ │ +│ ┌──────────────────┐ ┌──────────────────────────────────┐ │ +│ │ exceptions.py │ │ exception_handlers.py │ │ +│ └──────────────────┘ └──────────────────────────────────┘ │ +│ Database clients. WebSocket pool. Auth dependencies. │ +│ No server-side cryptography lives here — that's the client. │ +│ No imports from API or Services. │ ├─────────────────────────────────────────────────────────────────┤ -│ Model Layer (models/) │ -│ ┌──────┐ ┌────────────┐ ┌─────────────┐ ┌──────────────┐ │ -│ │ User │ │ Credential │ │ IdentityKey │ │ SignedPrekey │ │ -│ └──────┘ └────────────┘ └─────────────┘ └──────────────┘ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ -│ │ OneTimePrekey│ │ RatchetState │ │ SkippedMessageKey │ │ -│ └──────────────┘ └──────────────┘ └──────────────────────┘ │ -│ SQLModel ORM classes. Data structures. Validation. │ -│ Import only config constants and Base. │ +│ Model Layer (models/) │ +│ ┌──────┐ ┌────────────┐ ┌─────────────┐ ┌──────────────┐ │ +│ │ User │ │ Credential │ │ IdentityKey │ │ SignedPrekey │ │ +│ └──────┘ └────────────┘ └─────────────┘ └──────────────┘ │ +│ ┌──────────────┐ │ +│ │ OneTimePrekey│ Public-only prekey material — no privates. │ +│ └──────────────┘ │ +│ SQLModel ORM classes. Data structures. Validation. │ +│ Import only config constants and Base. │ ├─────────────────────────────────────────────────────────────────┤ -│ Schema Layer (schemas/) │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ -│ │ auth.py │ │ common.py│ │ rooms.py │ │ websocket.py │ │ -│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ -│ ┌──────────────┐ │ -│ │ surreal.py │ │ -│ └──────────────┘ │ -│ Pydantic request/response models. API contracts. │ -│ Serialization. No business logic. │ +│ Schema Layer (schemas/) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ +│ │ auth.py │ │ common.py│ │ rooms.py │ │ websocket.py │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ +│ ┌──────────────┐ │ +│ │ surreal.py │ │ +│ └──────────────┘ │ +│ Pydantic request/response models. API contracts. │ +│ Serialization. No business logic. │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -745,169 +740,125 @@ The only exception is `websocket_service.py`, which imports `connection_manager` ### PostgreSQL Schema (Auth + Keys) -All PostgreSQL models inherit from `BaseDBModel` (`models/Base.py:20-34`), which provides `created_at` and `updated_at` timestamp fields with timezone awareness. +All PostgreSQL models inherit from `BaseDBModel` (`models/Base.py`), which provides `created_at` and `updated_at` timestamp fields with timezone awareness. -**users** (`models/User.py:24-68`) +**users** (`models/User.py`) ``` ┌────────────────────────────────────────────────────────────┐ -│ users │ +│ users │ ├──────────────────────┬──────────────────┬──────────────────┤ -│ Column │ Type │ Constraints │ +│ Column │ Type │ Constraints │ ├──────────────────────┼──────────────────┼──────────────────┤ -│ id │ UUID │ PK, default uuid4│ -│ username │ VARCHAR(50) │ UNIQUE, INDEX │ -│ display_name │ VARCHAR(100) │ NOT NULL │ -│ is_active │ BOOLEAN │ default True │ -│ is_verified │ BOOLEAN │ default False │ -│ identity_key │ VARCHAR(500) │ nullable │ -│ signed_prekey │ VARCHAR(500) │ nullable │ -│ signed_prekey_sig │ VARCHAR(500) │ nullable │ -│ one_time_prekeys │ TEXT │ nullable │ -│ created_at │ TIMESTAMP(tz) │ NOT NULL │ -│ updated_at │ TIMESTAMP(tz) │ NOT NULL │ +│ id │ UUID │ PK, default uuid4│ +│ username │ VARCHAR(50) │ UNIQUE, INDEX │ +│ display_name │ VARCHAR(100) │ NOT NULL │ +│ is_active │ BOOLEAN │ default True │ +│ is_verified │ BOOLEAN │ default False │ +│ identity_key │ VARCHAR(500) │ nullable │ +│ signed_prekey │ VARCHAR(500) │ nullable │ +│ signed_prekey_sig │ VARCHAR(500) │ nullable │ +│ one_time_prekeys │ TEXT │ nullable │ +│ created_at │ TIMESTAMP(tz) │ NOT NULL │ +│ updated_at │ TIMESTAMP(tz) │ NOT NULL │ ├──────────────────────┴──────────────────┴──────────────────┤ -│ Relationships: credentials (1:many → Credential) │ +│ Relationships: credentials (1:many → Credential) │ └────────────────────────────────────────────────────────────┘ ``` -**credentials** (`models/Credential.py:27-78`) +**credentials** (`models/Credential.py`) ``` ┌────────────────────────────────────────────────────────────┐ -│ credentials │ +│ credentials │ ├──────────────────────┬──────────────────┬──────────────────┤ -│ Column │ Type │ Constraints │ +│ Column │ Type │ Constraints │ ├──────────────────────┼──────────────────┼──────────────────┤ -│ id │ INTEGER │ PK, autoincrement│ -│ credential_id │ VARCHAR(512) │ UNIQUE, INDEX │ -│ public_key │ VARCHAR(1024) │ NOT NULL │ -│ sign_count │ INTEGER │ default 0 │ -│ aaguid │ VARCHAR(64) │ nullable │ -│ backup_eligible │ BOOLEAN │ default False │ -│ backup_state │ BOOLEAN │ default False │ -│ attestation_type │ VARCHAR(50) │ nullable │ -│ transports │ VARCHAR(200) │ nullable │ -│ user_id │ UUID │ FK → users.id │ -│ device_name │ VARCHAR(100) │ nullable │ -│ last_used_at │ TIMESTAMP(tz) │ nullable │ -│ created_at │ TIMESTAMP(tz) │ NOT NULL │ -│ updated_at │ TIMESTAMP(tz) │ NOT NULL │ +│ id │ INTEGER │ PK, autoincrement│ +│ credential_id │ VARCHAR(512) │ UNIQUE, INDEX │ +│ public_key │ VARCHAR(1024) │ NOT NULL │ +│ sign_count │ INTEGER │ default 0 │ +│ aaguid │ VARCHAR(64) │ nullable │ +│ backup_eligible │ BOOLEAN │ default False │ +│ backup_state │ BOOLEAN │ default False │ +│ attestation_type │ VARCHAR(50) │ nullable │ +│ transports │ VARCHAR(200) │ nullable │ +│ user_id │ UUID │ FK → users.id │ +│ device_name │ VARCHAR(100) │ nullable │ +│ last_used_at │ TIMESTAMP(tz) │ nullable │ +│ created_at │ TIMESTAMP(tz) │ NOT NULL │ +│ updated_at │ TIMESTAMP(tz) │ NOT NULL │ ├──────────────────────┴──────────────────┴──────────────────┤ -│ Relationships: user (many:1 → User) │ +│ Relationships: user (many:1 → User) │ └────────────────────────────────────────────────────────────┘ ``` -**identity_keys** (`models/IdentityKey.py:18-48`) +**identity_keys** (`backend/app/models/IdentityKey.py`) ``` ┌────────────────────────────────────────────────────────────┐ -│ identity_keys │ +│ identity_keys │ ├──────────────────────┬──────────────────┬──────────────────┤ -│ Column │ Type │ Constraints │ +│ Column │ Type │ Constraints │ ├──────────────────────┼──────────────────┼──────────────────┤ -│ id │ INTEGER │ PK, autoincrement│ -│ user_id │ UUID │ FK → users.id │ -│ │ │ UNIQUE, INDEX │ -│ public_key │ VARCHAR(64) │ NOT NULL (X25519)│ -│ private_key │ VARCHAR(64) │ NOT NULL (X25519)│ -│ public_key_ed25519 │ VARCHAR(64) │ NOT NULL │ -│ private_key_ed25519 │ VARCHAR(64) │ NOT NULL │ -│ created_at │ TIMESTAMP(tz) │ NOT NULL │ -│ updated_at │ TIMESTAMP(tz) │ NOT NULL │ +│ id │ INTEGER │ PK, autoincrement│ +│ user_id │ UUID │ FK → users.id │ +│ │ │ UNIQUE, INDEX │ +│ public_key │ VARCHAR(64) │ NOT NULL (X25519)│ +│ public_key_ed25519 │ VARCHAR(64) │ NOT NULL │ +│ created_at │ TIMESTAMP(tz) │ NOT NULL │ +│ updated_at │ TIMESTAMP(tz) │ NOT NULL │ └────────────────────────────────────────────────────────────┘ ``` -Note: When client-side key generation is used (`store_client_keys`, `prekey_service.py:45-150`), the `private_key` and `private_key_ed25519` fields are stored as empty strings. Only the public keys are actually stored on the server. The private key fields remain in the schema for backward compatibility with the server-side key generation path. +Public-only. The server never stores identity private keys — those live exclusively in the client's IndexedDB. -**signed_prekeys** (`models/SignedPrekey.py:20-50`) +**signed_prekeys** (`backend/app/models/SignedPrekey.py`) ``` ┌────────────────────────────────────────────────────────────┐ -│ signed_prekeys │ +│ signed_prekeys │ ├──────────────────────┬──────────────────┬──────────────────┤ -│ Column │ Type │ Constraints │ +│ Column │ Type │ Constraints │ ├──────────────────────┼──────────────────┼──────────────────┤ -│ id │ INTEGER │ PK, autoincrement│ -│ user_id │ UUID │ FK → users.id │ -│ key_id │ INTEGER │ INDEX │ -│ public_key │ VARCHAR(64) │ NOT NULL (X25519)│ -│ private_key │ VARCHAR(64) │ NOT NULL │ -│ signature │ VARCHAR(128) │ NOT NULL (Ed2551)│ -│ is_active │ BOOLEAN │ default True │ -│ expires_at │ TIMESTAMP(tz) │ nullable │ -│ created_at │ TIMESTAMP(tz) │ NOT NULL │ -│ updated_at │ TIMESTAMP(tz) │ NOT NULL │ +│ id │ INTEGER │ PK, autoincrement│ +│ user_id │ UUID │ FK → users.id │ +│ key_id │ INTEGER │ INDEX │ +│ public_key │ VARCHAR(64) │ NOT NULL (X25519)│ +│ signature │ VARCHAR(128) │ NOT NULL (Ed25519)│ +│ is_active │ BOOLEAN │ default True │ +│ expires_at │ TIMESTAMP(tz) │ nullable │ +│ created_at │ TIMESTAMP(tz) │ NOT NULL │ +│ updated_at │ TIMESTAMP(tz) │ NOT NULL │ └────────────────────────────────────────────────────────────┘ ``` -Rotation: New signed prekey every 48 hours (`SIGNED_PREKEY_ROTATION_HOURS = 48`, `config.py:76`). Old inactive prekeys retained for 7 days (`SIGNED_PREKEY_RETENTION_DAYS = 7`, `config.py:77`) then cleaned up by `cleanup_old_signed_prekeys()` (`prekey_service.py:428-465`). +Rotation: the client generates a new signed prekey roughly every 48 hours (`SIGNED_PREKEY_ROTATION_HOURS`) and re-runs `uploadKeys` to publish the new public + signature. The previous active SPK is marked `is_active=False` by `store_client_keys` so the bundle endpoint always serves the latest. -**one_time_prekeys** (`models/OneTimePrekey.py:18-45`) +**one_time_prekeys** (`backend/app/models/OneTimePrekey.py`) ``` ┌────────────────────────────────────────────────────────────┐ -│ one_time_prekeys │ +│ one_time_prekeys │ ├──────────────────────┬──────────────────┬──────────────────┤ -│ Column │ Type │ Constraints │ +│ Column │ Type │ Constraints │ ├──────────────────────┼──────────────────┼──────────────────┤ -│ id │ INTEGER │ PK, autoincrement│ -│ user_id │ UUID │ FK → users.id │ -│ key_id │ INTEGER │ INDEX │ -│ public_key │ VARCHAR(64) │ NOT NULL (X25519)│ -│ private_key │ VARCHAR(64) │ NOT NULL │ -│ is_used │ BOOLEAN │ default False │ -│ │ │ INDEX │ -│ created_at │ TIMESTAMP(tz) │ NOT NULL │ -│ updated_at │ TIMESTAMP(tz) │ NOT NULL │ +│ id │ INTEGER │ PK, autoincrement│ +│ user_id │ UUID │ FK → users.id │ +│ key_id │ INTEGER │ INDEX │ +│ public_key │ VARCHAR(64) │ NOT NULL (X25519)│ +│ is_used │ BOOLEAN │ default False │ +│ │ │ INDEX │ +│ created_at │ TIMESTAMP(tz) │ NOT NULL │ +│ updated_at │ TIMESTAMP(tz) │ NOT NULL │ └────────────────────────────────────────────────────────────┘ ``` -Each user starts with 100 OPKs (`DEFAULT_ONE_TIME_PREKEY_COUNT = 100`, `config.py:75`). When the count drops below 20, the `get_prekey_bundle` endpoint auto-replenishes (`encryption.py:47-49`). +The client uploads OPK publics in batches of 100 (`DEFAULT_ONE_TIME_PREKEY_COUNT` in `frontend/src/types/encryption.ts`) and re-uploads when its local unused-OPK count drops below half. When `get_prekey_bundle` hands one out, it sets `is_used=True` so the same OPK is never offered to two senders. -**ratchet_states** (`models/RatchetState.py:18-68`) - -``` -┌────────────────────────────────────────────────────────────┐ -│ ratchet_states │ -├────────────────────────────┬──────────────┬────────────────┤ -│ Column │ Type │ Constraints │ -├────────────────────────────┼──────────────┼────────────────┤ -│ id │ INTEGER │ PK │ -│ user_id │ UUID │ FK → users.id │ -│ peer_user_id │ UUID │ FK → users.id │ -│ dh_private_key │ VARCHAR(100K)│ nullable │ -│ dh_public_key │ VARCHAR(100K)│ nullable │ -│ dh_peer_public_key │ VARCHAR(100K)│ nullable │ -│ root_key │ VARCHAR(100K)│ NOT NULL │ -│ sending_chain_key │ VARCHAR(100K)│ NOT NULL │ -│ receiving_chain_key │ VARCHAR(100K)│ NOT NULL │ -│ sending_message_number │ INTEGER │ default 0 │ -│ receiving_message_number │ INTEGER │ default 0 │ -│ previous_sending_chain_len │ INTEGER │ default 0 │ -│ created_at │ TIMESTAMP(tz)│ NOT NULL │ -│ updated_at │ TIMESTAMP(tz)│ NOT NULL │ -└────────────────────────────────────────────────────────────┘ -``` - -There is one ratchet state per (user_id, peer_user_id) pair. The relationship is directional: Alice's ratchet state for talking to Bob is a separate row from Bob's ratchet state for talking to Alice. - -**skipped_message_keys** (`models/SkippedMessageKey.py:17-51`) - -``` -┌────────────────────────────────────────────────────────────┐ -│ skipped_message_keys │ -├──────────────────────┬──────────────────┬──────────────────┤ -│ Column │ Type │ Constraints │ -├──────────────────────┼──────────────────┼──────────────────┤ -│ id │ INTEGER │ PK, autoincrement│ -│ ratchet_state_id │ INTEGER │ FK → ratchet_ │ -│ │ │ states.id, INDEX │ -│ dh_public_key │ VARCHAR(100000) │ NOT NULL, INDEX │ -│ message_number │ INTEGER │ NOT NULL, INDEX │ -│ message_key │ VARCHAR(100000) │ NOT NULL │ -│ created_at │ TIMESTAMP(tz) │ NOT NULL │ -│ updated_at │ TIMESTAMP(tz) │ NOT NULL │ +> Note: there are no `ratchet_states` or `skipped_message_keys` tables. The Double Ratchet — including its skipped-key cache — runs entirely in the browser and is persisted in IndexedDB by `frontend/src/crypto/key-store.ts:saveRatchetState`. Putting that state on the server would mean the server held the symmetric keys, defeating E2EE. +│ created_at │ TIMESTAMP(tz) │ NOT NULL │ +│ updated_at │ TIMESTAMP(tz) │ NOT NULL │ └────────────────────────────────────────────────────────────┘ ``` @@ -917,68 +868,68 @@ These store message keys for out-of-order delivery. When the receiving ratchet a SurrealDB is schemaless, but these are the document structures the application creates: -**messages** (created via `surreal_manager.py:112-122`) +**messages** (created via `surreal_manager.py`) ``` { - id: "messages:ulid_here", // SurrealDB auto-generated - sender_id: "uuid_string", - recipient_id: "uuid_string", - room_id: "rooms:ulid_here" | null, - ciphertext: "base64url_encoded_bytes", - nonce: "base64url_encoded_bytes", - header: "{\"dh_public_key\":\"...\",\"message_number\":0,...}", - sender_username: "alice", - created_at: "2026-01-15T10:30:00Z", - updated_at: "2026-01-15T10:30:00Z" + id: "messages:ulid_here", // SurrealDB auto-generated + sender_id: "uuid_string", + recipient_id: "uuid_string", + room_id: "rooms:ulid_here" | null, + ciphertext: "base64url_encoded_bytes", + nonce: "base64url_encoded_bytes", + header: "{\"dh_public_key\":\"...\",\"message_number\":0,...}", + sender_username: "alice", + created_at: "2026-01-15T10:30:00Z", + updated_at: "2026-01-15T10:30:00Z" } ``` -**presence** (created via `surreal_manager.py:287-305`) +**presence** (created via `surreal_manager.py`) ``` { - id: "presence:`user_uuid`", // user-specific record ID - user_id: "uuid_string", - status: "online" | "away" | "offline", - last_seen: "2026-01-15T10:30:00Z", - updated_at: "time::now()" + id: "presence:`user_uuid`", // user-specific record ID + user_id: "uuid_string", + status: "online" | "away" | "offline", + last_seen: "2026-01-15T10:30:00Z", + updated_at: "time::now" } ``` -**rooms** (created via `surreal_manager.py:155-182`) +**rooms** (created via `surreal_manager.py`) ``` { - id: "rooms:ulid_here", - name: "Room Name", - type: "direct" | "group", - members: ["uuid1", "uuid2"], - created_at: "2026-01-15T10:30:00Z", - updated_at: "2026-01-15T10:30:00Z" + id: "rooms:ulid_here", + name: "Room Name", + type: "direct" | "group", + members: ["uuid1", "uuid2"], + created_at: "2026-01-15T10:30:00Z", + updated_at: "2026-01-15T10:30:00Z" } ``` -**room_participants** (created via `surreal_manager.py:184-221`) +**room_participants** (created via `surreal_manager.py`) ``` { - id: "room_participants:ulid_here", - room_id: "rooms:ulid_here", - user_id: "uuid_string", - role: "member" | "admin", - joined_at: "2026-01-15T10:30:00Z" + id: "room_participants:ulid_here", + room_id: "rooms:ulid_here", + user_id: "uuid_string", + role: "member" | "admin", + joined_at: "2026-01-15T10:30:00Z" } ``` ### Redis Keys ``` -webauthn:reg_challenge:{username} → 32 bytes (hex-encoded), TTL 600s -webauthn:auth_challenge:{username} → 32 bytes (hex-encoded), TTL 600s +webauthn:reg_challenge:{username} → 32 bytes (hex-encoded), TTL 600s +webauthn:auth_challenge:{username} → 32 bytes (hex-encoded), TTL 600s ``` -These are the only two key patterns currently in use. Both use the Redis pipeline GET+DELETE pattern (`redis_manager.py:86-95`) for atomic one-time consumption. The TTL ensures stale challenges are automatically cleaned up even if the client never completes the flow. +These are the only two key patterns currently in use. Both use the Redis pipeline GET+DELETE pattern (`redis_manager.py`) for atomic one-time consumption. The TTL ensures stale challenges are automatically cleaned up even if the client never completes the flow. --- @@ -995,8 +946,8 @@ These are the only two key patterns currently in use. Both use the Redis pipelin | Stolen database dump | Key separation | PostgreSQL has public keys only (client-side path). Private keys live in browser IndexedDB. OPKs are single-use. | | Phishing / credential theft | WebAuthn | Credentials are origin-bound. Cannot be replayed on a different domain. | | Replay attacks | Nonces + counters | AES-GCM nonces are random. Message numbers are sequential. WebAuthn challenges have TTL. | -| Authenticator cloning | Signature counter | `passkey_manager.py:184-193` checks that the counter strictly increases. If it does not, authentication fails with a clone detection error. | -| Message tampering | AEAD | AES-256-GCM provides authenticated encryption. Tampered ciphertext fails the GCM tag check (`double_ratchet.py:143-153`). | +| Authenticator cloning | Signature counter | `backend/app/core/passkey/passkey_manager.py` checks that the counter strictly increases. If it does not, authentication fails with a clone detection error. | +| Message tampering | AEAD | AES-256-GCM provides authenticated encryption. Tampered ciphertext fails the GCM tag check (`frontend/src/crypto/double-ratchet.ts`). | | Key compromise (single key) | Forward secrecy | Double Ratchet generates new DH keys regularly. Compromising one chain key reveals only future messages in that chain, not past messages. | **What is out of scope:** @@ -1004,7 +955,7 @@ These are the only two key patterns currently in use. Both use the Redis pipelin | Threat | Why | |---|---| | Compromised client device | If the attacker has access to the browser, they have access to IndexedDB (private keys) and can read plaintext. There is no defense against a fully compromised endpoint. | -| Side-channel attacks on crypto | The `constantTimeEqual()` function in `primitives.ts:388-397` is the extent of timing attack mitigation. Comprehensive side-channel resistance would require constant-time implementations of all crypto primitives, which the WebCrypto API generally provides but does not guarantee. | +| Side-channel attacks on crypto | The `constantTimeEqual` function in `frontend/src/crypto/primitives.ts` is the extent of timing attack mitigation. Comprehensive side-channel resistance would require constant-time implementations of all crypto primitives, which the WebCrypto API generally provides but does not guarantee. | | Metadata analysis | The server knows who messages whom, when, how often, and message sizes. Only content is protected, not metadata. Protecting metadata would require something like mixnets or onion routing, which is not implemented. | | Quantum computing | X25519 and Ed25519 are not post-quantum. A sufficiently powerful quantum computer could break them. Post-quantum key exchange (e.g., ML-KEM/Kyber) is not implemented. | | Compromised authenticator supply chain | If the authenticator hardware itself is backdoored, WebAuthn cannot detect this. The AAGUID field can identify the authenticator model but not verify its integrity. | @@ -1013,64 +964,64 @@ These are the only two key patterns currently in use. Both use the Redis pipelin ``` Layer 1: Transport Security (TLS/HTTPS via Nginx) - │ - │ Protects: Data in transit between client and server - │ Mechanism: TLS certificate, HTTPS enforcement - │ Configuration: Nginx reverse proxy with SSL termination - │ - ▼ + │ + │ Protects: Data in transit between client and server + │ Mechanism: TLS certificate, HTTPS enforcement + │ Configuration: Nginx reverse proxy with SSL termination + │ + ▼ Layer 2: Authentication (WebAuthn/Passkeys) - │ - │ Protects: Identity verification, prevents impersonation - │ Mechanism: Public-key cryptography, hardware-bound keys - │ Key files: passkey_manager.py:43-210, auth_service.py:331-598 - │ Redis: Challenge storage with 600s TTL, one-time consumption - │ - ▼ + │ + │ Protects: Identity verification, prevents impersonation + │ Mechanism: Public-key cryptography, hardware-bound keys + │ Key files: backend/app/core/passkey/passkey_manager.py, backend/app/services/auth_service.py + │ Redis: Challenge storage with 600s TTL, one-time consumption + │ + ▼ Layer 3: Key Exchange (X3DH Protocol) - │ - │ Protects: Initial shared secret establishment - │ Mechanism: 3-4 Diffie-Hellman operations + HKDF - │ Key files: x3dh_manager.py:208-350, prekey_service.py:293-361 - │ Properties: Asynchronous (works even if recipient offline) - │ Deniable (either party could have forged the exchange) - │ - ▼ + │ + │ Protects: Initial shared secret establishment + │ Mechanism: 3-4 Diffie-Hellman operations + HKDF + │ Key files: frontend/src/crypto/x3dh.ts, backend/app/services/prekey_service.py + │ Properties: Asynchronous (works even if recipient offline) + │ Deniable (either party could have forged the exchange) + │ + ▼ Layer 4: Message Encryption (Double Ratchet + AES-256-GCM) - │ - │ Protects: Message confidentiality and integrity - │ Mechanism: Symmetric ratchet (HMAC chains) + DH ratchet - │ Key files: double_ratchet.py:64-419, primitives.ts:1-397 - │ Properties: Forward secrecy per-message - │ Future secrecy (self-healing after compromise) - │ - ▼ + │ + │ Protects: Message confidentiality and integrity + │ Mechanism: Symmetric ratchet (HMAC chains) + DH ratchet + │ Key files: frontend/src/crypto/double-ratchet.ts, frontend/src/crypto/primitives.ts + │ Properties: Forward secrecy per-message + │ Future secrecy (self-healing after compromise) + │ + ▼ Layer 5: Key Lifecycle Management - │ - │ Protects: Limits blast radius of any single key compromise - │ Mechanism: SPK rotation every 48h, OPK single-use, old SPK cleanup - │ Key files: prekey_service.py:221-291 (rotation), - │ prekey_service.py:428-465 (cleanup), - │ prekey_service.py:363-407 (replenishment) - │ Constants: SIGNED_PREKEY_ROTATION_HOURS=48 (config.py:76) - │ SIGNED_PREKEY_RETENTION_DAYS=7 (config.py:77) - │ DEFAULT_ONE_TIME_PREKEY_COUNT=100 (config.py:75) - │ - ▼ + │ + │ Protects: Limits blast radius of any single key compromise + │ Mechanism: SPK rotation every 48h, OPK single-use, old SPK cleanup + │ Key files: backend/app/services/prekey_service.py (rotation), + │ backend/app/services/prekey_service.py (cleanup), + │ backend/app/services/prekey_service.py (replenishment) + │ Constants: SIGNED_PREKEY_ROTATION_HOURS=48 (config.py) + │ SIGNED_PREKEY_RETENTION_DAYS=7 (config.py) + │ DEFAULT_ONE_TIME_PREKEY_COUNT=100 (config.py) + │ + ▼ Layer 6: Rate Limiting and Abuse Prevention - │ - Protects: Against brute force and DoS - Mechanism: Per-user message rate limits, auth attempt limits - Constants: RATE_LIMIT_MESSAGES_PER_MINUTE=60 (config.py:146) - RATE_LIMIT_AUTH_ATTEMPTS=5 (config.py:147) - WS_MAX_CONNECTIONS_PER_USER=5 (config.py:141) + │ + Protects: Against brute force and DoS + Mechanism: Per-user message rate limits, auth attempt limits + Constants: RATE_LIMIT_MESSAGES_PER_MINUTE=60 (config.py) + RATE_LIMIT_AUTH_ATTEMPTS=5 (config.py) + WS_MAX_CONNECTIONS_PER_USER=5 (config.py) ``` --- ## Configuration -All configuration lives in `config.py`. The `Settings` class (lines 96-218) inherits from `pydantic_settings.BaseSettings` and loads values from environment variables and the `.env` file. +All configuration lives in `config.py`. The `Settings` class inherits from `pydantic_settings.BaseSettings` and loads values from environment variables and the `.env` file. ### Application Settings @@ -1090,11 +1041,11 @@ All configuration lives in `config.py`. The `Settings` class (lines 96-218) inhe | `POSTGRES_DB` | `"chat_auth"` | Database name | | `POSTGRES_USER` | `"chat_user"` | Database user | | `POSTGRES_PASSWORD` | `""` | Database password | -| `DATABASE_URL` | (auto-built) | Full connection string, assembled by `field_validator` at line 149 | +| `DATABASE_URL` | (auto-built) | Full connection string, assembled by `field_validator` | | `DB_POOL_SIZE` | `20` | SQLAlchemy connection pool size | | `DB_MAX_OVERFLOW` | `40` | Pool overflow connections | -The `assemble_db_connection` validator (`config.py:149-161`) builds the URL from components if `DATABASE_URL` is not explicitly set. The URL uses the `postgresql+asyncpg://` scheme for async connections. +The `assemble_db_connection` validator (`config.py`) builds the URL from components if `DATABASE_URL` is not explicitly set. The URL uses the `postgresql+asyncpg://` scheme for async connections. ### SurrealDB Settings @@ -1106,7 +1057,7 @@ The `assemble_db_connection` validator (`config.py:149-161`) builds the URL from | `SURREAL_PASSWORD` | (required) | SurrealDB password | | `SURREAL_NAMESPACE` | `"chat"` | SurrealDB namespace | | `SURREAL_DATABASE` | `"production"` | SurrealDB database | -| `SURREAL_URL` | (auto-built) | WebSocket URL, assembled at line 163 | +| `SURREAL_URL` | (auto-built) | WebSocket URL, assembled | ### Redis Settings @@ -1115,7 +1066,7 @@ The `assemble_db_connection` validator (`config.py:149-161`) builds the URL from | `REDIS_HOST` | `"localhost"` | Redis host | | `REDIS_PORT` | `6379` | Redis port | | `REDIS_PASSWORD` | `""` | Redis password (optional) | -| `REDIS_URL` | (auto-built) | Connection URL, assembled at line 178 | +| `REDIS_URL` | (auto-built) | Connection URL, assembled | ### WebAuthn Settings @@ -1145,42 +1096,42 @@ The `RP_ID` must match the domain the browser sees. In production, this would be ### Database Connection Pooling -PostgreSQL pool: 20 base connections + 40 overflow = 60 max simultaneous connections (`config.py:118-119`). The engine uses `pool_pre_ping=True` (`models/Base.py:43`) to detect and replace stale connections before queries fail. +PostgreSQL pool: 20 base connections + 40 overflow = 60 max simultaneous connections (`config.py`). The engine uses `pool_pre_ping=True` (`models/Base.py`) to detect and replace stale connections before queries fail. -SQLAlchemy async sessions are created per-request via the `get_session()` dependency (`models/Base.py:54-59`). Sessions are recycled automatically after each request. +SQLAlchemy async sessions are created per-request via the `get_session` dependency (`models/Base.py`). Sessions are recycled automatically after each request. -Redis pool: 50 connections (`redis_manager.py:39`). Connection pooling is handled by `redis.asyncio.ConnectionPool`. +Redis pool: 50 connections (`redis_manager.py`). Connection pooling is handled by `redis.asyncio.ConnectionPool`. -SurrealDB: Single persistent WebSocket connection per application instance (`surreal_manager.py:47-48`). The `ensure_connected()` method (`surreal_manager.py:73-78`) lazily reconnects if the connection drops. +SurrealDB: Single persistent WebSocket connection per application instance (`surreal_manager.py`). The `ensure_connected` method (`surreal_manager.py`) lazily reconnects if the connection drops. ### Message Throughput -60 messages per minute per user rate limit (`config.py:146`). This translates to one message per second sustained, which is reasonable for a chat application. +60 messages per minute per user rate limit (`config.py`). This translates to one message per second sustained, which is reasonable for a chat application. -WebSocket heartbeat every 30 seconds (`config.py:140`). This is frequent enough to detect dead connections quickly but not so frequent as to waste bandwidth on idle connections. +WebSocket heartbeat every 30 seconds (`config.py`). This is frequent enough to detect dead connections quickly but not so frequent as to waste bandwidth on idle connections. -SurrealDB live queries are push-based (`surreal_manager.py:341-359`). When a new message is created, SurrealDB pushes it to the subscribed callback immediately. There is no polling interval, so latency is limited to network round-trip plus SurrealDB processing time. +SurrealDB live queries are push-based (`surreal_manager.py`). When a new message is created, SurrealDB pushes it to the subscribed callback immediately. There is no polling interval, so latency is limited to network round-trip plus SurrealDB processing time. ### Encryption Overhead Approximate per-message encryption cost (based on typical X25519/AES-256-GCM performance): ``` -X25519 DH exchange: ~0.1ms +X25519 DH exchange: ~0.1ms HMAC-SHA256 chain step: ~0.01ms -AES-256-GCM encrypt: ~0.001ms per KB (for typical chat messages) -HKDF derivation: ~0.01ms +AES-256-GCM encrypt: ~0.001ms per KB (for typical chat messages) +HKDF derivation: ~0.01ms -Total per-message: < 1ms +Total per-message: < 1ms ``` This is negligible compared to network latency (typically 10-100ms). Encryption overhead is not a bottleneck for this application. ### Connection Limits -5 WebSocket connections per user (`config.py:141`). This supports a reasonable number of devices (phone, laptop, tablet, desktop, secondary browser) without allowing a single user to exhaust server WebSocket capacity. +5 WebSocket connections per user (`config.py`). This supports a reasonable number of devices (phone, laptop, tablet, desktop, secondary browser) without allowing a single user to exhaust server WebSocket capacity. -The `ConnectionManager` delivers each message to all of a user's connections (`websocket_manager.py:135-153`), so the cost of multi-device scales linearly with the number of connections per user. +The `ConnectionManager` delivers each message to all of a user's connections (`websocket_manager.py`), so the cost of multi-device scales linearly with the number of connections per user. --- @@ -1212,13 +1163,11 @@ SolidJS also has a smaller bundle size than React, which helps with initial load The tradeoff is ecosystem size. React has far more libraries, tutorials, and Stack Overflow answers. SolidJS is smaller and you occasionally need to build something that React would have a library for. -### Why Separate Server and Client Encryption Paths? +### Why Only a Client-Side Encryption Path? -`message_service.py` has both `send_encrypted_message` (server-side encryption, line 316, marked `[DEPRECATED]`) and `store_encrypted_message` (client-side passthrough, line 269). The server-side path exists because the system was originally built with server-side encryption and is being migrated to full client-side encryption. +An earlier draft of this project carried two encryption paths — a server-side one that loaded ratchet state from PostgreSQL and encrypted on the backend, and a client-side passthrough that stored opaque ciphertext. The audit (see `docs/plans/`) deleted the server-side path entirely, including its `RatchetState` and `SkippedMessageKey` tables and the entire `app/core/encryption/` Python module. -In the server-side path, the server loads the ratchet state, encrypts the plaintext, and stores the ciphertext. This means the server momentarily has access to the plaintext. In the client-side path, the server never sees the plaintext; it receives and stores ciphertext as-is. - -The client-side path is the correct production path. The server-side path remains for backwards compatibility and as a fallback during the migration. +Why? Because as long as a server-side X3DH/Double Ratchet implementation exists, the schema columns it needs (private keys, ratchet roots) exist, and any caller can hit it — the server has the technical capability to read user messages. "Marked deprecated" is a comment, not a defense. The only way to honestly claim end-to-end encryption is to make it physically impossible for the server to participate. So now `message_service.py` has exactly one method, `store_encrypted_message`, and it does nothing but pass `ciphertext` / `nonce` / `header` through to SurrealDB. --- @@ -1230,25 +1179,25 @@ The client-side path is the correct production path. The server-side path remain docker compose -f dev.compose.yml up ┌───────────────────────────────────────────────────────────────┐ -│ Docker Network (chat_network_dev) │ -│ │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ PostgreSQL │ │ SurrealDB │ │ Redis │ │ -│ │ 16-alpine │ │ latest │ │ 8-alpine │ │ -│ │ :5432→5432 │ │ :8000→8001 │ │ :6379→6379 │ │ -│ │ │ │ file://data│ │ appendonly │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -│ │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ FastAPI │ │ Vite Dev │ │ Nginx │ │ -│ │ :8000→8000 │ │ :5173→5173 │ │ :80→80 │ │ -│ │ uvicorn │ │ HMR enabled│ │ reverse │ │ -│ │ --reload │ │ hot module │ │ proxy │ │ -│ │ vol: ./back │ │ vol: ./fro │ │ dev.nginx │ │ -│ │ end │ │ ntend │ │ config │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -│ │ -│ Source: dev.compose.yml │ +│ Docker Network (chat_network_dev) │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ PostgreSQL │ │ SurrealDB │ │ Redis │ │ +│ │ 16-alpine │ │ latest │ │ 8-alpine │ │ +│ │ :5432→5432 │ │ :8000→8001 │ │ :6379→6379 │ │ +│ │ │ │ file://data│ │ appendonly │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ FastAPI │ │ Vite Dev │ │ Nginx │ │ +│ │ :8000→8000 │ │ :5173→5173 │ │ :80→80 │ │ +│ │ uvicorn │ │ HMR enabled│ │ reverse │ │ +│ │ --reload │ │ hot module │ │ proxy │ │ +│ │ vol: ./back │ │ vol: ./fro │ │ dev.nginx │ │ +│ │ end │ │ ntend │ │ config │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ Source: dev.compose.yml │ └───────────────────────────────────────────────────────────────┘ ``` @@ -1260,39 +1209,39 @@ In development, all services expose ports to the host for direct access and debu docker compose up ┌───────────────────────────────────────────────────────────────┐ -│ Docker Network (chat_network) │ -│ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Nginx (:80, :443) │ │ -│ │ │ │ -│ │ /api/* ────────► upstream: backend:8000 (HTTP) │ │ -│ │ /ws ────────► upstream: backend:8000 (WebSocket) │ │ -│ │ /* ────────► Static SolidJS build (from image) │ │ -│ │ │ │ -│ └──────────────────────────┬───────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────────┼──────────────────────────────┐ │ -│ │ │ │ │ -│ │ ┌─────────────┐ ┌─────┴─────┐ ┌─────────────┐ │ │ -│ │ │ PostgreSQL │ │ FastAPI │ │ SurrealDB │ │ │ -│ │ │ 16-alpine │ │ gunicorn │ │ latest │ │ │ -│ │ │ vol: data │ │ workers │ │ vol: data │ │ │ -│ │ │ healthcheck │ │ no ports │ │ healthcheck│ │ │ -│ │ │ restart: │ │ exposed │ │ restart: │ │ │ -│ │ │ always │ │ to host │ │ always │ │ │ -│ │ └─────────────┘ └───────────┘ └─────────────┘ │ │ -│ │ │ │ -│ │ ┌─────────────┐ │ │ -│ │ │ Redis │ │ │ -│ │ │ 8-alpine │ │ │ -│ │ │ vol: data │ │ │ -│ │ │ maxmem 2gb │ │ │ -│ │ │ LRU evict │ │ │ -│ │ └─────────────┘ │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ -│ Source: compose.yml │ +│ Docker Network (chat_network) │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Nginx (:80, :443) │ │ +│ │ │ │ +│ │ /api/* ────────► upstream: backend:8000 (HTTP) │ │ +│ │ /ws ────────► upstream: backend:8000 (WebSocket) │ │ +│ │ /* ────────► Static SolidJS build (from image) │ │ +│ │ │ │ +│ └──────────────────────────┬───────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────┼──────────────────────────────┐ │ +│ │ │ │ │ +│ │ ┌─────────────┐ ┌─────┴─────┐ ┌─────────────┐ │ │ +│ │ │ PostgreSQL │ │ FastAPI │ │ SurrealDB │ │ │ +│ │ │ 16-alpine │ │ gunicorn │ │ latest │ │ │ +│ │ │ vol: data │ │ workers │ │ vol: data │ │ │ +│ │ │ healthcheck │ │ no ports │ │ healthcheck│ │ │ +│ │ │ restart: │ │ exposed │ │ restart: │ │ │ +│ │ │ always │ │ to host │ │ always │ │ │ +│ │ └─────────────┘ └───────────┘ └─────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────┐ │ │ +│ │ │ Redis │ │ │ +│ │ │ 8-alpine │ │ │ +│ │ │ vol: data │ │ │ +│ │ │ maxmem 2gb │ │ │ +│ │ │ LRU evict │ │ │ +│ │ └─────────────┘ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ Source: compose.yml │ └───────────────────────────────────────────────────────────────┘ ``` @@ -1306,30 +1255,28 @@ The compose file (`compose.yml`) uses `depends_on` with `condition: service_heal ### Custom Exception Hierarchy -Defined in `core/exceptions.py` (lines 1-95): +Defined in `backend/app/core/exceptions.py`: ``` AppException (base) - ├── UserExistsError → 409 Conflict - ├── UserNotFoundError → 404 Not Found - ├── UserInactiveError → 403 Forbidden - ├── CredentialNotFoundError → 404 Not Found - ├── CredentialVerificationError → 401 Unauthorized - ├── ChallengeExpiredError → 400 Bad Request - ├── DatabaseError → 500 Internal Server Error - ├── AuthenticationError → 401 Unauthorized - ├── InvalidDataError → 400 Bad Request - ├── EncryptionError → 500 Internal Server Error - ├── DecryptionError → 500 Internal Server Error - ├── RatchetStateNotFoundError → 404 Not Found - └── KeyExchangeError → 500 Internal Server Error + ├── UserExistsError → 409 Conflict + ├── UserNotFoundError → 404 Not Found + ├── UserInactiveError → 403 Forbidden + ├── CredentialNotFoundError → 404 Not Found + ├── CredentialVerificationError → 401 Unauthorized + ├── ChallengeExpiredError → 400 Bad Request + ├── DatabaseError → 500 Internal Server Error + ├── AuthenticationError → 401 Unauthorized + └── InvalidDataError → 400 Bad Request ``` +`slowapi.errors.RateLimitExceeded` is also handled here and maps to 429 Too Many Requests. + ### Exception Handlers -Registered in `core/exception_handlers.py` (lines 221-246) via `register_exception_handlers(app)`, which is called from `factory.py:89`. Each exception type maps to a handler function that returns a `JSONResponse` with the appropriate HTTP status code. +Registered in `backend/app/core/exception_handlers.py` via `register_exception_handlers(app)`, called from `factory.py`. Each exception type maps to a handler function that returns a `JSONResponse` with the appropriate HTTP status code. -For security-sensitive errors (`DatabaseError`, `EncryptionError`, `DecryptionError`, `KeyExchangeError`), the response body contains a generic message ("Internal server error", "Encryption failed", etc.) rather than the actual error detail. The detail is logged server-side but not exposed to the client. +For security-sensitive errors (`DatabaseError`), the response body contains a generic message ("Internal server error") rather than the actual error detail. The detail is logged server-side but not exposed to the client. ### WebSocket Error Format @@ -1337,13 +1284,13 @@ WebSocket errors are sent as JSON to the client: ```json { - "type": "error", - "error_code": "invalid_json", - "error_message": "Invalid JSON format" + "type": "error", + "error_code": "invalid_json", + "error_message": "Invalid JSON format" } ``` -Error codes include: `max_connections`, `database_error`, `invalid_json`, `missing_type`, `unknown_type`, `processing_error`. These are defined inline in `websocket.py` (lines 62-76) and `websocket_manager.py` (lines 58-62). +Error codes include: `max_connections`, `database_error`, `invalid_json`, `missing_type`, `unknown_type`, `processing_error`. These are defined inline in `websocket.py` and `websocket_manager.py` . --- @@ -1353,10 +1300,10 @@ Where to add new features: | What | Where | Steps | |---|---|---| -| New API endpoint | `api/` directory | 1. Create router in `api/new_feature.py` 2. Create service in `services/new_feature_service.py` 3. Register router in `factory.py` (after line 113) | -| New database model | `models/` directory | 1. Create model class inheriting `BaseDBModel` 2. Import it somewhere that loads at startup (e.g., `models/__init__.py`) 3. `init_db()` auto-creates the table via `SQLModel.metadata.create_all` | -| New encryption algorithm | `core/encryption/` | 1. Add implementation in `core/encryption/` 2. Add config constants in `config.py` 3. Wire into `message_service.py` or `crypto-service.ts` | -| New WebSocket message type | `websocket_service.py` | 1. Add constant in `config.py` (WS_MESSAGE_TYPE_*) 2. Add handler method in `WebSocketService` 3. Add routing case in `route_message()` | +| New API endpoint | `api/` directory | 1. Create router in `api/new_feature.py` 2. Create service in `services/new_feature_service.py` 3. Register router in `factory.py` (after) | +| New database model | `models/` directory | 1. Create model class inheriting `BaseDBModel` 2. Import it somewhere that loads at startup (e.g., `models/__init__.py`) 3. `init_db` auto-creates the table via `SQLModel.metadata.create_all` | +| New encryption algorithm | `frontend/src/crypto/` | 1. Add implementation in `frontend/src/crypto/` 2. Add config constants in `config.py` 3. Wire into `message_service.py` or `crypto-service.ts` | +| New WebSocket message type | `websocket_service.py` | 1. Add constant in `config.py` (WS_MESSAGE_TYPE_*) 2. Add handler method in `WebSocketService` 3. Add routing case in `route_message` | | New SurrealDB collection | `surreal_manager.py` | 1. Add CRUD methods in `SurrealDBManager` 2. Add response schema in `schemas/surreal.py` | --- @@ -1371,62 +1318,48 @@ These are known architectural limitations, not bugs: 3. **Metadata not protected.** The server knows who sends messages to whom, when, how frequently, and the approximate size of each message. Only the content is encrypted. Metadata protection would require techniques like onion routing, padding, or dummy traffic, all of which add significant complexity and performance cost. -4. **No message deletion or expiry.** Once a message is stored in SurrealDB, it stays there indefinitely. There is no TTL on messages and no "delete for everyone" feature. SurrealDB does support the `_schedule_room_deletion` method for ephemeral rooms (`surreal_manager.py:393-425`), but this is room-level, not message-level. +4. **No message deletion or expiry.** Once a message is stored in SurrealDB, it stays there indefinitely. There is no TTL on messages and no "delete for everyone" feature. SurrealDB does support the `_schedule_room_deletion` method for ephemeral rooms (`surreal_manager.py`), but this is room-level, not message-level. 5. **Single-region deployment.** The Docker Compose setup assumes all services run on one machine or one cluster. There is no geo-distribution, no CDN for the frontend, and no database replication. For a production deployment serving users across regions, you would need to add these. -6. **No offline message queue.** If a recipient is offline when a message is sent, the message is stored in SurrealDB but only delivered when the recipient connects and the live query fires. There is no explicit mechanism for fetching missed messages on reconnect beyond the live query catching up. The `get_room_messages` method (`surreal_manager.py:124-153`) exists for fetching message history, but the client must explicitly call it. +6. **No offline message queue.** If a recipient is offline when a message is sent, the message is stored in SurrealDB but only delivered when the recipient connects and the live query fires. There is no explicit mechanism for fetching missed messages on reconnect beyond the live query catching up. The `get_room_messages` method (`surreal_manager.py`) exists for fetching message history, but the client must explicitly call it. --- ## Key Files Reference -| What | Where | Lines | -|---|---|---| -| App factory | `factory.py` | 63-115 | -| Lifespan (DB init) | `factory.py` | 39-61 | -| All settings | `config.py` | 96-218 | -| Crypto constants | `config.py` | 64-77 | -| X3DH protocol | `core/encryption/x3dh_manager.py` | 56-353 | -| X3DH sender | `core/encryption/x3dh_manager.py` | 208-281 | -| X3DH receiver | `core/encryption/x3dh_manager.py` | 283-350 | -| Double Ratchet | `core/encryption/double_ratchet.py` | 64-419 | -| Encrypt message | `core/encryption/double_ratchet.py` | 323-362 | -| Decrypt message | `core/encryption/double_ratchet.py` | 364-416 | -| WebAuthn manager | `core/passkey/passkey_manager.py` | 43-210 | -| Clone detection | `core/passkey/passkey_manager.py` | 184-193 | -| WebSocket pool | `core/websocket_manager.py` | 31-296 | -| Heartbeat loop | `core/websocket_manager.py` | 177-201 | -| Live query sub | `core/websocket_manager.py` | 203-223 | -| Live msg handler | `core/websocket_manager.py` | 225-251 | -| SurrealDB client | `core/surreal_manager.py` | 28-428 | -| Redis client | `core/redis_manager.py` | 19-174 | -| Exception types | `core/exceptions.py` | 7-95 | -| Exception handlers | `core/exception_handlers.py` | 221-246 | -| Message storage | `services/message_service.py` | 269-314 | -| Conversation init | `services/message_service.py` | 48-166 | -| Key management | `services/prekey_service.py` | 41-468 | -| Client key upload | `services/prekey_service.py` | 45-150 | -| Prekey bundle | `services/prekey_service.py` | 293-361 | -| SPK rotation | `services/prekey_service.py` | 221-291 | -| OPK replenish | `services/prekey_service.py` | 363-407 | -| Auth service | `services/auth_service.py` | 47-601 | -| WS message router | `services/websocket_service.py` | 35-324 | -| Auth endpoints | `api/auth.py` | 31-103 | -| Encryption endpoints | `api/encryption.py` | 21-127 | -| WebSocket endpoint | `api/websocket.py` | 25-84 | -| User model | `models/User.py` | 24-68 | -| Credential model | `models/Credential.py` | 27-78 | -| Identity key model | `models/IdentityKey.py` | 18-48 | -| Signed prekey model | `models/SignedPrekey.py` | 20-50 | -| One-time prekey model | `models/OneTimePrekey.py` | 18-45 | -| Ratchet state model | `models/RatchetState.py` | 18-68 | -| Skipped keys model | `models/SkippedMessageKey.py` | 17-51 | -| DB engine + sessions | `models/Base.py` | 37-67 | -| Client crypto prims | `frontend/src/crypto/primitives.ts` | 1-397 | -| Client crypto svc | `frontend/src/crypto/crypto-service.ts` | - | -| Client double ratchet | `frontend/src/crypto/double-ratchet.ts` | - | -| Client X3DH | `frontend/src/crypto/x3dh.ts` | - | -| Client key store | `frontend/src/crypto/key-store.ts` | - | -| Production compose | `compose.yml` | 1-118 | -| Development compose | `dev.compose.yml` | 1-151 | +| What | Where | +|---|---| +| App factory | `backend/app/factory.py` | +| Lifespan (DB init) | `backend/app/factory.py` (`lifespan`) | +| Settings + crypto constants | `backend/app/config.py` | +| Auth dependency | `backend/app/core/dependencies.py` (`current_user`) | +| Session helpers | `backend/app/core/dependencies.py` (`issue_session`, `revoke_session`) | +| WebAuthn manager | `backend/app/core/passkey/passkey_manager.py` | +| Clone detection | `passkey_manager.PasskeyManager.verify_authentication` | +| WebSocket pool | `backend/app/core/websocket_manager.py` | +| SurrealDB client | `backend/app/core/surreal_manager.py` | +| Redis client (sessions, challenges) | `backend/app/core/redis_manager.py` | +| Exception types | `backend/app/core/exceptions.py` | +| Exception handlers | `backend/app/core/exception_handlers.py` | +| Pass-through message storage | `backend/app/services/message_service.py` (`store_encrypted_message`) | +| Public prekey storage + bundle lookup | `backend/app/services/prekey_service.py` | +| Auth service | `backend/app/services/auth_service.py` | +| WS message router (membership-checked) | `backend/app/services/websocket_service.py` | +| Auth endpoints (incl. `/auth/me`, `/auth/logout`) | `backend/app/api/auth.py` | +| Encryption endpoints (bundle + upload) | `backend/app/api/encryption.py` | +| Rooms (membership-gated CRUD) | `backend/app/api/rooms.py` | +| WebSocket endpoint (cookie-authenticated) | `backend/app/api/websocket.py` | +| User model (with `webauthn_user_handle`) | `backend/app/models/User.py` | +| Credential model | `backend/app/models/Credential.py` | +| Identity public keys | `backend/app/models/IdentityKey.py` | +| Signed prekey publics | `backend/app/models/SignedPrekey.py` | +| One-time prekey publics | `backend/app/models/OneTimePrekey.py` | +| DB engine + sessions | `backend/app/models/Base.py` | +| Client crypto primitives | `frontend/src/crypto/primitives.ts` | +| Client crypto orchestration | `frontend/src/crypto/crypto-service.ts` | +| Client Double Ratchet | `frontend/src/crypto/double-ratchet.ts` | +| Client X3DH | `frontend/src/crypto/x3dh.ts` | +| Client IndexedDB key store | `frontend/src/crypto/key-store.ts` | +| Production compose | `compose.yml` | +| Development compose | `dev.compose.yml` | diff --git a/PROJECTS/advanced/encrypted-p2p-chat/learn/03-IMPLEMENTATION.md b/PROJECTS/advanced/encrypted-p2p-chat/learn/03-IMPLEMENTATION.md index a3760484..c170c7cf 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/learn/03-IMPLEMENTATION.md +++ b/PROJECTS/advanced/encrypted-p2p-chat/learn/03-IMPLEMENTATION.md @@ -8,99 +8,95 @@ This document walks through every significant piece of the encrypted P2P chat, f ``` backend/ - app/ - api/ - auth.py # WebAuthn registration/login endpoints - encryption.py # Key upload, prekey bundle retrieval - rooms.py # Chat room creation and listing - websocket.py # WebSocket endpoint (receives raw frames) - core/ - encryption/ - x3dh_manager.py # X3DH key exchange: generate, sign, exchange - double_ratchet.py # Double Ratchet: KDF chains, encrypt, decrypt - passkey/ - passkey_manager.py # WebAuthn: registration, auth, clone detection - websocket_manager.py # Connection pool, heartbeats, live query sub - surreal_manager.py # SurrealDB client (messages, live queries) - redis_manager.py # Redis client (challenges, presence) - exceptions.py # Custom exception hierarchy - exception_handlers.py # FastAPI exception-to-HTTP mappings - enums.py # Presence status enum - models/ - Base.py # SQLModel engine, async session maker - User.py # User table (id, username, display_name) - Credential.py # WebAuthn credential storage - IdentityKey.py # X25519 + Ed25519 identity keypairs - SignedPrekey.py # Signed prekeys with rotation/expiry - OneTimePrekey.py # Single-use one-time prekeys - RatchetState.py # Serialized Double Ratchet state per conversation - SkippedMessageKey.py # Persisted skipped message keys - schemas/ - auth.py # Pydantic schemas for registration/auth flows - websocket.py # Pydantic schemas for WS message types - surreal.py # Pydantic schemas for SurrealDB documents - rooms.py # Pydantic schemas for room operations - common.py # Shared response schemas - services/ - auth_service.py # User creation, credential management - prekey_service.py # Key initialization, rotation, bundle retrieval - message_service.py # Conversation init, encrypt, store, decrypt - websocket_service.py # WS message routing (type dispatch) - presence_service.py # Online/offline/away status via Redis - config.py # All constants + Settings (env vars) - factory.py # FastAPI app factory with middleware - main.py # Uvicorn entry point + app/ + api/ + auth.py # WebAuthn registration/login endpoints + encryption.py # Key upload, prekey bundle retrieval + rooms.py # Chat room creation and listing + websocket.py # WebSocket endpoint (receives raw frames) + core/ + passkey/ + passkey_manager.py # WebAuthn: registration, auth, clone detection + dependencies.py # current_user, issue_session, revoke_session + websocket_manager.py # Connection pool, heartbeats, live query sub + surreal_manager.py # SurrealDB client (messages, live queries) + redis_manager.py # Redis client (sessions, challenges) + exceptions.py # Custom exception hierarchy + exception_handlers.py # FastAPI exception-to-HTTP mappings + enums.py # Presence status enum + models/ + Base.py # SQLModel engine, async session maker + User.py # User table (id, username, webauthn_user_handle, ...) + Credential.py # WebAuthn credential storage (BE/BS, sign_count) + IdentityKey.py # X25519 + Ed25519 identity public keys (no privates) + SignedPrekey.py # Signed prekey publics + signature + OneTimePrekey.py # Single-use prekey publics (is_used flag) + schemas/ + auth.py # Pydantic schemas for registration/auth flows + websocket.py # Pydantic schemas for WS message types + surreal.py # Pydantic schemas for SurrealDB documents + rooms.py # Pydantic schemas for room operations + common.py # Shared response schemas + services/ + auth_service.py # User creation, credential management, search + prekey_service.py # Public-only prekey storage + bundle lookup + message_service.py # Pass-through ciphertext writer + websocket_service.py # WS routing + per-user rate cap + membership + presence_service.py # Online/offline/away status via SurrealDB + config.py # All constants + Settings (env vars) + factory.py # FastAPI app factory with middleware + main.py # Uvicorn entry point frontend/ - src/ - crypto/ - primitives.ts # Low-level WebCrypto: X25519, Ed25519, AES-GCM, HKDF, HMAC - x3dh.ts # Client-side X3DH: generate keys, initiate, receive - double-ratchet.ts # Client-side Double Ratchet: init, encrypt, decrypt, serialize - crypto-service.ts # High-level orchestrator: session management, key upload - key-store.ts # IndexedDB persistence for all key material - message-store.ts # Message history persistence - websocket/ - websocket-manager.ts # WS connection, reconnect, heartbeat, message queue - message-handlers.ts # Dispatch incoming WS messages to stores - stores/ - auth.store.ts # Authentication state (nanostores) - session.store.ts # Session token management - messages.store.ts # Message list per room - rooms.store.ts # Room list and active room - presence.store.ts # Online status per user - typing.store.ts # Typing indicator state - ui.store.ts # UI state (sidebar open, modals) - settings.store.ts # User preferences - services/ - auth.service.ts # WebAuthn browser API calls - room.service.ts # Room CRUD via API - components/ - Auth/ # Login, Register, PasskeyButton - Chat/ # MessageList, ChatInput, ConversationList, etc. - Layout/ # AppShell, Sidebar, Header, ProtectedRoute - UI/ # Button, Modal, Input, Tooltip, etc. - pages/ - Chat.tsx # Main chat page (conversation + message pane) - Login.tsx # Login page - Register.tsx # Registration page - Home.tsx # Landing page - NotFound.tsx # 404 - types/ - encryption.ts # Type definitions for crypto structures - chat.ts # Message and room types - websocket.ts # WS message types - auth.ts # Auth types - guards.ts # Runtime type guards for WS messages - api.ts # API response types - components.ts # Component prop types - index.ts # Re-exports + constants - lib/ - api-client.ts # HTTP client for REST endpoints - base64.ts # Base64 helpers - validators.ts # Input validation - date.ts # Date formatting - config.ts # Frontend constants (URLs, timeouts) + src/ + crypto/ + primitives.ts # Low-level WebCrypto: X25519, Ed25519, AES-GCM, HKDF, HMAC + x3dh.ts # Client-side X3DH: generate keys, initiate, receive + double-ratchet.ts # Client-side Double Ratchet: init, encrypt, decrypt, serialize + crypto-service.ts # High-level orchestrator: session management, key upload + key-store.ts # IndexedDB persistence for all key material + message-store.ts # Message history persistence + websocket/ + websocket-manager.ts # WS connection, reconnect, heartbeat, message queue + message-handlers.ts # Dispatch incoming WS messages to stores + stores/ + auth.store.ts # Authentication state (nanostores) + session.store.ts # Session token management + messages.store.ts # Message list per room + rooms.store.ts # Room list and active room + presence.store.ts # Online status per user + typing.store.ts # Typing indicator state + ui.store.ts # UI state (sidebar open, modals) + settings.store.ts # User preferences + services/ + auth.service.ts # WebAuthn browser API calls + room.service.ts # Room CRUD via API + components/ + Auth/ # Login, Register, PasskeyButton + Chat/ # MessageList, ChatInput, ConversationList, etc. + Layout/ # AppShell, Sidebar, Header, ProtectedRoute + UI/ # Button, Modal, Input, Tooltip, etc. + pages/ + Chat.tsx # Main chat page (conversation + message pane) + Login.tsx # Login page + Register.tsx # Registration page + Home.tsx # Landing page + NotFound.tsx # 404 + types/ + encryption.ts # Type definitions for crypto structures + chat.ts # Message and room types + websocket.ts # WS message types + auth.ts # Auth types + guards.ts # Runtime type guards for WS messages + api.ts # API response types + components.ts # Component prop types + index.ts # Re-exports + constants + lib/ + api-client.ts # HTTP client for REST endpoints + base64.ts # Base64 helpers + validators.ts # Input validation + date.ts # Date formatting + config.ts # Frontend constants (URLs, timeouts) ``` --- @@ -111,30 +107,30 @@ X3DH (Extended Triple Diffie-Hellman) solves a specific problem: Alice wants to ### Step 1: Key Generation -The foundation of X3DH is generating two types of identity keypairs per user. Here is the X25519 identity key generation from `x3dh_manager.py:60-86`: +The foundation of X3DH is generating two types of identity keypairs per user. Here is the X25519 identity key generation from `frontend/src/crypto/x3dh.ts`: ```python def generate_identity_keypair_x25519(self) -> tuple[str, str]: - private_key = X25519PrivateKey.generate() - public_key = private_key.public_key() + 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 - ) + 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 + ) - return ( - bytes_to_base64url(private_bytes), - bytes_to_base64url(public_bytes) - ) + return ( + bytes_to_base64url(private_bytes), + bytes_to_base64url(public_bytes) + ) ``` -There is also `generate_identity_keypair_ed25519` at `x3dh_manager.py:88-114`, which follows the exact same pattern but uses `Ed25519PrivateKey.generate()` instead. +There is also `generate_identity_keypair_ed25519` at `frontend/src/crypto/x3dh.ts`, which follows the exact same pattern but uses `Ed25519PrivateKey.generate` instead. **Why Raw encoding?** PEM and DER formats include metadata headers, algorithm identifiers, and ASN.1 structure. That is wasted bytes when you already know the algorithm on both sides. Raw encoding gives you exactly 32 bytes for X25519 and 32 bytes for Ed25519. That is the minimum footprint, and it maps directly to what the WebCrypto API expects on the frontend when importing with `importKey("raw", ...)`. @@ -144,88 +140,88 @@ There is also `generate_identity_keypair_ed25519` at `x3dh_manager.py:88-114`, w ### Step 2: Signed Prekey Creation -The Signed Prekey (SPK) is a semi-static X25519 key that gets rotated every 48 hours. Here is `generate_signed_prekey` from `x3dh_manager.py:116-152`: +The Signed Prekey (SPK) is a semi-static X25519 key that gets rotated every 48 hours. Here is `generate_signed_prekey` from `frontend/src/crypto/x3dh.ts`: ```python def generate_signed_prekey(self, - identity_private_key_ed25519: str) -> tuple[str, - str, - str]: - spk_private = X25519PrivateKey.generate() - spk_public = spk_private.public_key() + identity_private_key_ed25519: str) -> tuple[str, + str, + str]: + 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 - ) + 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 - ) + 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) + signature = identity_private.sign(spk_public_bytes) - return ( - bytes_to_base64url(spk_private_bytes), - bytes_to_base64url(spk_public_bytes), - bytes_to_base64url(signature) - ) + return ( + bytes_to_base64url(spk_private_bytes), + bytes_to_base64url(spk_public_bytes), + bytes_to_base64url(signature) + ) ``` **Why sign with Ed25519?** The signature proves that Bob's long-term Ed25519 identity key endorses this particular SPK. When Alice fetches Bob's prekey bundle, she verifies this signature before performing the DH operations. Without the signature, a man-in-the-middle could substitute their own SPK public key into Bob's bundle. Alice would compute a shared secret with the attacker instead of Bob, and the attacker could relay messages between them while reading everything. **What does the signature prove?** It proves exactly one thing: "The entity controlling the Ed25519 private key corresponding to Bob's published Ed25519 public key chose to sign this specific SPK public key." That binds the SPK to Bob's identity. -**Why not self-sign with X25519?** X25519 is a DH function, not a signature scheme. There is no `sign()` method on an X25519 key. You could theoretically convert between Curve25519 and Ed25519 key formats (they share the same underlying curve), but doing that conversion is a footgun. It is error-prone, not all libraries support it, and it violates the principle of key separation. Using a dedicated Ed25519 signing key is simpler and safer. +**Why not self-sign with X25519?** X25519 is a DH function, not a signature scheme. There is no `sign` method on an X25519 key. You could theoretically convert between Curve25519 and Ed25519 key formats (they share the same underlying curve), but doing that conversion is a footgun. It is error-prone, not all libraries support it, and it violates the principle of key separation. Using a dedicated Ed25519 signing key is simpler and safer. ### Step 3: Prekey Bundle Assembly -When Alice wants to message Bob, the server assembles Bob's prekey bundle. This happens in `prekey_service.py:293-361`: +When Alice wants to message Bob, the server assembles Bob's prekey bundle. This happens in `backend/app/services/prekey_service.py`: ```python async def get_prekey_bundle( - self, - session: AsyncSession, - user_id: UUID + self, + session: AsyncSession, + user_id: UUID ) -> PreKeyBundle: - # Fetch identity key - ik_statement = select(IdentityKey).where(IdentityKey.user_id == user_id) - ... - # Fetch active signed prekey - spk_statement = select(SignedPrekey).where( - SignedPrekey.user_id == user_id, - SignedPrekey.is_active - ).order_by(SignedPrekey.created_at.desc()) - ... - if not signed_prekey: - signed_prekey = await self.rotate_signed_prekey(session, user_id) + # Fetch identity key + ik_statement = select(IdentityKey).where(IdentityKey.user_id == user_id) + ... + # Fetch active signed prekey + spk_statement = select(SignedPrekey).where( + SignedPrekey.user_id == user_id, + SignedPrekey.is_active + ).order_by(SignedPrekey.created_at.desc) + ... + if not signed_prekey: + signed_prekey = await self.rotate_signed_prekey(session, user_id) - # Fetch one unused OPK - opk_statement = select(OneTimePrekey).where( - OneTimePrekey.user_id == user_id, - not OneTimePrekey.is_used - ).limit(1) - ... - if one_time_prekey: - one_time_prekey.is_used = True - ... + # Fetch one unused OPK + opk_statement = select(OneTimePrekey).where( + OneTimePrekey.user_id == user_id, + not OneTimePrekey.is_used + ).limit(1) + ... + if one_time_prekey: + one_time_prekey.is_used = True + ... ``` -**Why auto-rotate?** If Bob's signed prekey expired and there are no active ones, the bundle fetch would fail. Auto-rotation at `prekey_service.py:316-321` ensures there is always an active SPK. The rotation generates a new X25519 keypair, signs it with Bob's Ed25519 identity key, marks old SPKs as inactive, and sets a 48-hour expiry (`SIGNED_PREKEY_ROTATION_HOURS = 48` in `config.py:76`). +**Why auto-rotate?** If Bob's signed prekey expired and there are no active ones, the bundle fetch would fail. Auto-rotation at `backend/app/services/prekey_service.py` ensures there is always an active SPK. The rotation generates a new X25519 keypair, signs it with Bob's Ed25519 identity key, marks old SPKs as inactive, and sets a 48-hour expiry (`SIGNED_PREKEY_ROTATION_HOURS = 48` in `config.py`). **Why mark OPK as used?** One-Time Prekeys provide forward secrecy for the initial handshake. Each OPK is consumed exactly once. When Alice fetches Bob's bundle, the server marks that OPK as `is_used = True` and commits the change. If a second person also tries to start a conversation with Bob around the same time, they get a different OPK (or none at all). This single-use guarantee means that even if Bob's long-term keys are later compromised, an attacker cannot retroactively compute the shared secret for conversations that used an OPK, because the OPK private key was consumed and the DH output from it cannot be reconstructed from the other keys alone. -**What happens if no OPK is available?** The protocol still works. The `PreKeyBundle` dataclass has `one_time_prekey: str | None = None`. When the bundle is assembled without an OPK, the X3DH sender side simply computes `dh1 + dh2 + dh3` instead of `dh1 + dh2 + dh3 + dh4`. This gives slightly weaker forward secrecy for the initial handshake (it relies on the signed prekey not being compromised), but the Double Ratchet provides forward secrecy for all subsequent messages regardless. The system compensates by calling `replenish_one_time_prekeys` periodically to maintain a pool of 100 OPKs (`DEFAULT_ONE_TIME_PREKEY_COUNT = 100` in `config.py:75`). +**What happens if no OPK is available?** The protocol still works. The `PreKeyBundle` dataclass has `one_time_prekey: str | None = None`. When the bundle is assembled without an OPK, the X3DH sender side simply computes `dh1 + dh2 + dh3` instead of `dh1 + dh2 + dh3 + dh4`. This gives slightly weaker forward secrecy for the initial handshake (it relies on the signed prekey not being compromised), but the Double Ratchet provides forward secrecy for all subsequent messages regardless. The system compensates by calling `replenish_one_time_prekeys` periodically to maintain a pool of 100 OPKs (`DEFAULT_ONE_TIME_PREKEY_COUNT = 100` in `config.py`). ### Step 4: X3DH Sender Side -This is the core of the initial key agreement. Here is the DH operations from `x3dh_manager.py:241-264`: +This is the core of the initial key agreement. Here is the DH operations from `frontend/src/crypto/x3dh.ts`: ```python dh1 = alice_ik_private.exchange(bob_spk_public) @@ -234,25 +230,25 @@ 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 + 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 + 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', + algorithm = hashes.SHA256, + length = X25519_KEY_SIZE, + salt = b'\x00' * X25519_KEY_SIZE, + info = b'X3DH', ) shared_key = hkdf.derive(f + key_material) ``` -Before this code runs, the sender verifies the signed prekey signature at `x3dh_manager.py:217-220`. If verification fails, a `ValueError` is raised and the exchange aborts. This is the MITM protection. +Before this code runs, the sender verifies the signed prekey signature at `frontend/src/crypto/x3dh.ts`. If verification fails, a `ValueError` is raised and the exchange aborts. This is the MITM protection. **Each DH operation explained:** @@ -267,22 +263,22 @@ Before this code runs, the sender verifies the signed prekey signature at `x3dh_ **Why info="X3DH"?** The `info` parameter in HKDF is a domain separation string. It ensures that the derived key is bound to this specific protocol. If the same key material were accidentally reused in a different context (say, a TLS handshake), the info string would produce a different derived key. This prevents cross-protocol attacks. -**Associated data: IK_A || IK_B.** After deriving the shared key, the code computes `associated_data = alice_ik_public_bytes + bob_ik_public_bytes` at `x3dh_manager.py:272`. This associated data is later passed to AES-GCM as the AAD (Additional Authenticated Data). It binds the encrypted messages to the specific pair of identity keys that performed the handshake. If an attacker tries to redirect messages between different users, the AAD check will fail during decryption. +**Associated data: IK_A || IK_B.** After deriving the shared key, the code computes `associated_data = alice_ik_public_bytes + bob_ik_public_bytes` at `frontend/src/crypto/x3dh.ts`. This associated data is later passed to AES-GCM as the AAD (Additional Authenticated Data). It binds the encrypted messages to the specific pair of identity keys that performed the handshake. If an attacker tries to redirect messages between different users, the AAD check will fail during decryption. ### Step 5: X3DH Receiver Side -The receiver-side logic lives at `x3dh_manager.py:283-350`. The operations are mirrored: +The receiver-side logic lives at `frontend/src/crypto/x3dh.ts`. The operations are mirrored: ```python -dh1 = bob_spk_private.exchange(alice_ik_public) # mirrors alice_ik.exchange(bob_spk) -dh2 = bob_ik_private.exchange(alice_ek_public) # mirrors alice_ek.exchange(bob_ik) -dh3 = bob_spk_private.exchange(alice_ek_public) # mirrors alice_ek.exchange(bob_spk) -dh4 = bob_opk_private.exchange(alice_ek_public) # mirrors alice_ek.exchange(bob_opk) +dh1 = bob_spk_private.exchange(alice_ik_public) # mirrors alice_ik.exchange(bob_spk) +dh2 = bob_ik_private.exchange(alice_ek_public) # mirrors alice_ek.exchange(bob_ik) +dh3 = bob_spk_private.exchange(alice_ek_public) # mirrors alice_ek.exchange(bob_spk) +dh4 = bob_opk_private.exchange(alice_ek_public) # mirrors alice_ek.exchange(bob_opk) ``` This works because of how Diffie-Hellman is defined: `A_private.exchange(B_public) == B_private.exchange(A_public)`. The math is commutative. Both sides compute the same four 32-byte DH outputs, concatenate them in the same order, and derive the same shared key through the same HKDF parameters (zero salt, `X3DH` info, `0xFF` padding). -The receiver also constructs `associated_data = alice_ik_public_bytes + bob_ik_public_bytes` in the same order (Alice's IK first, Bob's IK second) at `x3dh_manager.py:341`. Order matters. If Alice put hers first on the sender side but Bob reversed the order on the receiver side, the associated data would not match and decryption would fail. +The receiver also constructs `associated_data = alice_ik_public_bytes + bob_ik_public_bytes` in the same order (Alice's IK first, Bob's IK second) at `frontend/src/crypto/x3dh.ts`. Order matters. If Alice put hers first on the sender side but Bob reversed the order on the receiver side, the associated data would not match and decryption would fail. ### Common Mistakes @@ -291,7 +287,7 @@ The receiver also constructs `associated_data = alice_ik_public_bytes + bob_ik_p ```python # WRONG - breaks compatibility between sender and receiver salt = os.urandom(32) -hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, info=b'X3DH') +hkdf = HKDF(algorithm=hashes.SHA256, length=32, salt=salt, info=b'X3DH') ``` If you use a random salt, the sender and receiver will derive different shared keys. The sender would need to transmit the salt alongside the ephemeral key, and at that point you have added complexity for no security gain since the DH outputs already provide the randomness. @@ -301,12 +297,12 @@ If you use a random salt, the sender and receiver will derive different shared k ```python # WRONG - allows MITM to substitute their own SPK def perform_x3dh_sender(self, alice_identity_private_x25519, bob_bundle, ...): - # Skipped: self.verify_signed_prekey(...) - alice_ik_private_bytes = base64url_to_bytes(alice_identity_private_x25519) - ... + # Skipped: self.verify_signed_prekey(...) + alice_ik_private_bytes = base64url_to_bytes(alice_identity_private_x25519) + ... ``` -Without signature verification, an attacker who controls the network can replace Bob's SPK public key with their own. Alice would compute a shared secret with the attacker. The attacker then performs X3DH with Bob using Bob's real SPK, and relays messages between them. Neither Alice nor Bob detects the interception. The fix is on `x3dh_manager.py:217-220`: verify before exchanging. +Without signature verification, an attacker who controls the network can replace Bob's SPK public key with their own. Alice would compute a shared secret with the attacker. The attacker then performs X3DH with Bob using Bob's real SPK, and relays messages between them. Neither Alice nor Bob detects the interception. The fix is on `frontend/src/crypto/x3dh.ts`: verify before exchanging. **BAD: Reusing one-time prekeys** @@ -317,53 +313,53 @@ opk = get_opk_for_user(user_id) # another conversation uses the same OPK ``` -If two conversations use the same OPK, an attacker who later compromises Bob's OPK private key can compute the DH4 output for both conversations. That defeats the purpose of single-use keys. The fix is at `prekey_service.py:332`: `one_time_prekey.is_used = True` immediately upon retrieval, followed by `session.commit()`. +If two conversations use the same OPK, an attacker who later compromises Bob's OPK private key can compute the DH4 output for both conversations. That defeats the purpose of single-use keys. The fix is at `backend/app/services/prekey_service.py`: `one_time_prekey.is_used = True` immediately upon retrieval, followed by `session.commit`. ### Frontend X3DH Implementation -The frontend mirrors the backend X3DH in TypeScript using the WebCrypto API. The client-side sender logic lives in `x3dh.ts:115-173`: +The frontend mirrors the backend X3DH in TypeScript using the WebCrypto API. The client-side sender logic lives in `x3dh.ts`: ```typescript export async function initiateX3DH( - identityKeyPair: IdentityKeyPair, - recipientBundle: PreKeyBundle + identityKeyPair: IdentityKeyPair, + recipientBundle: PreKeyBundle ): Promise { - const signatureValid = await verifySignedPreKey( - recipientBundle.identity_key_ed25519, - recipientBundle.signed_prekey, - recipientBundle.signed_prekey_signature - ) - if (!signatureValid) { - throw new Error("Invalid signed prekey signature") - } - ... - const dh1 = await x25519DeriveSharedSecret(senderIdentityPrivate, recipientSignedPreKeyPublic) - const dh2 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientIdentityPublic) - const dh3 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientSignedPreKeyPublic) - ... - const sharedKey = await hkdfDerive(concatenated, EMPTY_SALT, X3DH_INFO, 32) + const signatureValid = await verifySignedPreKey( + recipientBundle.identity_key_ed25519, + recipientBundle.signed_prekey, + recipientBundle.signed_prekey_signature + ) + if (!signatureValid) { + throw new Error("Invalid signed prekey signature") + } + ... + const dh1 = await x25519DeriveSharedSecret(senderIdentityPrivate, recipientSignedPreKeyPublic) + const dh2 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientIdentityPublic) + const dh3 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientSignedPreKeyPublic) + ... + const sharedKey = await hkdfDerive(concatenated, EMPTY_SALT, X3DH_INFO, 32) ``` -The HKDF derivation used here is from `primitives.ts:166-192`: +The HKDF derivation used here is from `frontend/src/crypto/primitives.ts`: ```typescript export async function hkdfDerive( - inputKeyMaterial: Uint8Array, - salt: Uint8Array, - info: Uint8Array, - outputLength: number = HKDF_OUTPUT_SIZE + inputKeyMaterial: Uint8Array, + salt: Uint8Array, + info: Uint8Array, + outputLength: number = HKDF_OUTPUT_SIZE ): Promise { - const baseKey = await subtle.importKey( - "raw", inputKeyMaterial.buffer as ArrayBuffer, - { name: "HKDF" }, false, ["deriveBits"] - ) - const derivedBits = await subtle.deriveBits( - { name: "HKDF", hash: "SHA-256", - salt: salt.buffer as ArrayBuffer, - info: info.buffer as ArrayBuffer }, - baseKey, outputLength * 8 - ) - return new Uint8Array(derivedBits) + const baseKey = await subtle.importKey( + "raw", inputKeyMaterial.buffer as ArrayBuffer, + { name: "HKDF" }, false, ["deriveBits"] + ) + const derivedBits = await subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", + salt: salt.buffer as ArrayBuffer, + info: info.buffer as ArrayBuffer }, + baseKey, outputLength * 8 + ) + return new Uint8Array(derivedBits) } ``` @@ -371,7 +367,7 @@ There is an important difference between the backend and frontend HKDF calls. Th The WebCrypto API's `subtle.deriveBits` with the `HKDF` algorithm handles the extract-then-expand steps internally. You provide the input key material, a salt, an info string, and the desired output length in bits (not bytes, which is why `outputLength * 8` appears). The `importKey` call with `{ name: "HKDF" }` creates a non-extractable key object suitable only for derivation, which prevents the raw key material from being accidentally exported or misused. -The receiver-side X3DH in `x3dh.ts:175-215` mirrors the sender with the same key swaps as the Python version. The commutative property of ECDH ensures both sides derive the same 32-byte shared key. +The receiver-side X3DH in `x3dh.ts` mirrors the sender with the same key swaps as the Python version. The commutative property of ECDH ensures both sides derive the same 32-byte shared key. --- @@ -381,130 +377,132 @@ The Double Ratchet provides ongoing forward secrecy and break-in recovery after ### Initialization -The sender initializes at `double_ratchet.py:279-302`: +The sender initializes via `initializeRatchetSender` in `frontend/src/crypto/double-ratchet.ts`: -```python -def initialize_sender( - self, - shared_key: bytes, - peer_public_key: bytes -) -> DoubleRatchetState: - dh_private = X25519PrivateKey.generate() - peer_public = X25519PublicKey.from_public_bytes(peer_public_key) - dh_output = dh_private.exchange(peer_public) +```ts +export async function initializeRatchetSender( + peerId: string, + sharedKey: Uint8Array, + peerPublicKey: Uint8Array, +): Promise { + const dhKeyPair = await generateX25519KeyPair + const dhPublicKey = await exportPublicKey(dhKeyPair.publicKey) + const peerKey = await importX25519PublicKey(peerPublicKey) + const dhOutput = await x25519DeriveSharedSecret(dhKeyPair.privateKey, peerKey) - root_key, sending_chain_key = self._kdf_rk(shared_key, dh_output) + // KDF_RK per spec: HKDF(salt = previous_root_key, IKM = dh_output, info = "DoubleRatchet") + const derivedKeys = await hkdfDerive(dhOutput, sharedKey, RATCHET_INFO, 64) + const rootKey = derivedKeys.slice(0, 32) + const sendingChainKey = derivedKeys.slice(32, 64) - 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 - ) - return state + return { peer_id: peerId, root_key: rootKey, sending_chain_key: sendingChainKey, /* ... */ } +} ``` -The receiver initializes at `double_ratchet.py:304-321`: +The receiver initializes via `initializeRatchetReceiver`: -```python -def initialize_receiver( - self, - shared_key: bytes, - own_private_key: X25519PrivateKey -) -> DoubleRatchetState: - 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 - ) - return state +```ts +export async function initializeRatchetReceiver( + peerId: string, + sharedKey: Uint8Array, + dhKeyPair: CryptoKeyPair, +): Promise { + const dhPublicKey = await exportPublicKey(dhKeyPair.publicKey) + return { + peer_id: peerId, + root_key: sharedKey, + sending_chain_key: new Uint8Array(0), + receiving_chain_key: null, + dh_private_key: dhKeyPair, + dh_public_key: dhPublicKey, + dh_peer_public_key: null, + /* ... */ + } +} ``` **Why does the sender do an extra DH step?** The sender generates a fresh DH keypair and immediately performs a DH exchange with Bob's SPK public key. This produces a `dh_output` which gets fed into `_kdf_rk` along with the X3DH `shared_key` to derive the first `root_key` and `sending_chain_key`. This extra step means the first message already has a DH ratchet contribution beyond the X3DH output. The receiver, by contrast, does not perform this step during initialization. It simply stores the X3DH shared key as the root key and waits for the first message, which will carry the sender's new DH public key and trigger the receiver's first ratchet step. ### KDF Chain Operations -The root key chain derivation at `double_ratchet.py:79-94`: +The root key chain derivation at `frontend/src/crypto/double-ratchet.ts`: ```python def _kdf_rk(self, root_key: bytes, dh_output: bytes) -> tuple[bytes, bytes]: - 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 :] - return new_root_key, new_chain_key + 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 :] + return new_root_key, new_chain_key ``` `_kdf_rk` is the root chain KDF. It runs every time a DH ratchet step occurs (when the sender or receiver generates a new DH keypair). The `root_key` is used as the HKDF salt, and the fresh DH output is the input key material. HKDF produces 64 bytes (`HKDF_OUTPUT_SIZE * 2 = 64`), which is split in half: the first 32 bytes become the new root key, and the second 32 bytes become the new chain key. The root key never directly encrypts anything. It acts as a "master key" that seeds each new chain. Because the root key is updated with fresh DH output every ratchet step, even if an attacker compromises the current root key, the next ratchet step will incorporate a new DH output that the attacker does not know (assuming the DH private key is secure), and the root key will be "healed." This is the break-in recovery property. The empty `info` parameter is intentional. The Signal spec does not use an info string for the root chain KDF. Domain separation happens implicitly because the root chain HKDF always uses the root key as salt, whereas the X3DH HKDF uses a zero salt. Different salts produce different outputs even with the same input, so there is no cross-contamination. -The chain key derivation at `double_ratchet.py:96-109`: +The chain key derivation at `frontend/src/crypto/double-ratchet.ts`: ```python def _kdf_ck(self, chain_key: bytes) -> tuple[bytes, bytes]: - h_chain = hmac.HMAC(chain_key, hashes.SHA256()) - h_chain.update(b'\x01') - next_chain_key = h_chain.finalize() + 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() + h_message = hmac.HMAC(chain_key, hashes.SHA256) + h_message.update(b'\x02') + message_key = h_message.finalize - return next_chain_key, message_key + return next_chain_key, message_key ``` **Why HMAC with 0x01 and 0x02?** The chain key needs to produce two outputs: the next chain key and the message key. Using HMAC with different single-byte constants is the simplest way to derive two independent values from one input. `0x01` produces the next chain key, `0x02` produces the message key. The constants are arbitrary but fixed by convention (the Signal spec uses these exact values). The important thing is that they are different, so the two HMAC outputs are cryptographically independent. -**Why two separate HMACs?** You cannot reuse an HMAC object after calling `finalize()` in the Python `cryptography` library. Each HMAC computation is a fresh instance. Even if you could reuse it, producing two different outputs from the same key requires two different inputs, which means two operations. +**Why two separate HMACs?** You cannot reuse an HMAC object after calling `finalize` in the Python `cryptography` library. Each HMAC computation is a fresh instance. Even if you could reuse it, producing two different outputs from the same key requires two different inputs, which means two operations. **What is the one-way property?** Given `next_chain_key = HMAC(chain_key, 0x01)`, you cannot compute `chain_key` from `next_chain_key`. HMAC is a one-way function. This means that if an attacker obtains a message key for message N, they cannot derive the chain key that produced it, and therefore cannot derive message keys for messages 0 through N-1. This is forward secrecy within a single chain. ### Message Encryption -Here is the encrypt path from `double_ratchet.py:323-362`: +Here is the encrypt path from `frontend/src/crypto/double-ratchet.ts`: ```python def encrypt_message(self, state, plaintext, associated_data): - 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) + 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 + 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 - return encrypted_msg + 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 + return encrypted_msg ``` -And the underlying AES-GCM encryption at `double_ratchet.py:111-130`: +And the underlying AES-GCM encryption at `frontend/src/crypto/double-ratchet.ts`: ```python def _encrypt_with_message_key(self, message_key, plaintext, associated_data): - aesgcm = AESGCM(message_key) - nonce = os.urandom(AES_GCM_NONCE_SIZE) - ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data) - return nonce, ciphertext + aesgcm = AESGCM(message_key) + nonce = os.urandom(AES_GCM_NONCE_SIZE) + ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data) + return nonce, ciphertext ``` **Why include dh_public_key in the header?** The recipient needs the sender's current DH public key to determine whether to perform a DH ratchet step. If the DH public key in the message header differs from the last known peer DH key, the recipient knows the sender has ratcheted, and the recipient must also ratchet to derive the correct receiving chain key. Without this header field, the recipient would have no way to know which chain to use. @@ -513,41 +511,41 @@ def _encrypt_with_message_key(self, message_key, plaintext, associated_data): ### Message Decryption -The decrypt path at `double_ratchet.py:364-416` handles three distinct cases: +The decrypt path at `frontend/src/crypto/double-ratchet.ts` handles three distinct cases: ```python def decrypt_message(self, state, encrypted_msg, associated_data): - # Case 1: Check if we already stored this message's key (out-of-order) - 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 - ) + # Case 1: Check if we already stored this message's key (out-of-order) + 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 + ) - # Case 2: New DH ratchet key means sender ratcheted - 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) + # Case 2: New DH ratchet key means sender ratcheted + 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) - # Case 3: Message number ahead of current position means skipped messages - if encrypted_msg.message_number > state.receiving_message_number: - self._store_skipped_message_keys( - state, encrypted_msg.message_number, encrypted_msg.dh_public_key - ) + # Case 3: Message number ahead of current position means skipped messages + if encrypted_msg.message_number > state.receiving_message_number: + self._store_skipped_message_keys( + state, encrypted_msg.message_number, encrypted_msg.dh_public_key + ) - # Advance chain and decrypt - state.receiving_chain_key, message_key = self._kdf_ck(state.receiving_chain_key) - state.receiving_message_number += 1 + # Advance chain and decrypt + 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 - ) - return plaintext + plaintext = self._decrypt_with_message_key( + message_key, encrypted_msg.nonce, encrypted_msg.ciphertext, associated_data + ) + return plaintext ``` **Case 1: Skipped key hit.** The message key was already computed and stored during a previous skip operation. Pop it from the dict, decrypt, done. This handles previously skipped messages arriving late. @@ -558,32 +556,32 @@ def decrypt_message(self, state, encrypted_msg, associated_data): ### Out-of-Order Handling -The skip mechanism at `double_ratchet.py:215-244`: +The skip mechanism at `frontend/src/crypto/double-ratchet.ts`: ```python def _store_skipped_message_keys(self, state, until_message_number, dh_public_key): - num_to_skip = until_message_number - state.receiving_message_number + 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 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: - self._evict_oldest_skipped_keys(state, num_to_skip) + if len(state.skipped_message_keys) + num_to_skip > self.max_cache: + 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 + 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 + state.receiving_chain_key = chain_key ``` -**MAX_SKIP = 1000** (`config.py:73`). If an attacker sends a message claiming `message_number = 999999999`, the receiver would need to compute 999999999 HMAC operations to derive all the intermediate keys. That is a denial-of-service attack. The limit of 1000 means the receiver will reject any message that would require skipping more than 1000 positions. In practice, even aggressive out-of-order delivery rarely exceeds a few dozen skipped messages. +**MAX_SKIP = 1000** (`config.py`). If an attacker sends a message claiming `message_number = 999999999`, the receiver would need to compute 999999999 HMAC operations to derive all the intermediate keys. That is a denial-of-service attack. The limit of 1000 means the receiver will reject any message that would require skipping more than 1000 positions. In practice, even aggressive out-of-order delivery rarely exceeds a few dozen skipped messages. -**MAX_CACHE = 2000** (`config.py:74`). Even with the skip limit, a determined attacker could slowly accumulate skipped keys by sending many small gaps. The cache limit prevents unbounded memory growth. When the cache is full, `_evict_oldest_skipped_keys` removes the oldest entries to make room. Oldest entries are the ones most likely to be genuinely lost (if a message has not arrived after 2000 other skipped keys were stored, it is probably never coming). +**MAX_CACHE = 2000** (`config.py`). Even with the skip limit, a determined attacker could slowly accumulate skipped keys by sending many small gaps. The cache limit prevents unbounded memory growth. When the cache is full, `_evict_oldest_skipped_keys` removes the oldest entries to make room. Oldest entries are the ones most likely to be genuinely lost (if a message has not arrived after 2000 other skipped keys were stored, it is probably never coming). ### Common Mistakes @@ -591,7 +589,7 @@ def _store_skipped_message_keys(self, state, until_message_number, dh_public_key **Not evicting old skipped keys (memory exhaustion).** Without eviction, the `skipped_message_keys` dict grows without bound. An attacker can cause OOM by sending many messages with incrementing but non-sequential message numbers across multiple ratchet steps. -**Reusing nonces (catastrophic for AES-GCM).** AES-GCM uses a 12-byte nonce. If the same nonce is used twice with the same key, an attacker can XOR the two ciphertexts to eliminate the keystream and recover both plaintexts. The code uses `os.urandom(AES_GCM_NONCE_SIZE)` at `double_ratchet.py:122` for a fresh random nonce every time. Since each message key is used exactly once (each chain key advances after derivation), and each message key gets a random nonce, the probability of a collision is negligible. But if someone modified the code to reuse message keys, the random nonce would be the only defense, and with 12 bytes, birthday collisions become likely after about 2^48 messages under the same key. +**Reusing nonces (catastrophic for AES-GCM).** AES-GCM uses a 12-byte nonce. If the same nonce is used twice with the same key, an attacker can XOR the two ciphertexts to eliminate the keystream and recover both plaintexts. The code uses `os.urandom(AES_GCM_NONCE_SIZE)` at `frontend/src/crypto/double-ratchet.ts` for a fresh random nonce every time. Since each message key is used exactly once (each chain key advances after derivation), and each message key gets a random nonce, the probability of a collision is negligible. But if someone modified the code to reuse message keys, the random nonce would be the only defense, and with 12 bytes, birthday collisions become likely after about 2^48 messages under the same key. --- @@ -599,58 +597,58 @@ def _store_skipped_message_keys(self, state, until_message_number, dh_public_key ### Registration Flow -Registration is handled in `passkey_manager.py:55-94`. The server generates registration options: +Registration is handled in `backend/app/core/passkey/passkey_manager.py`. The server generates registration options: ```python def generate_registration_options( - self, user_id, username, display_name, exclude_credentials + self, user_id, username, display_name, exclude_credentials ): - challenge = secrets.token_bytes(WEBAUTHN_CHALLENGE_BYTES) - ... - options = generate_registration_options( - rp_id = self.rp_id, - rp_name = self.rp_name, - user_id = user_id, - user_name = username, - challenge = challenge, - attestation = AttestationConveyancePreference.NONE, - authenticator_selection = AuthenticatorSelectionCriteria( - resident_key = ResidentKeyRequirement.REQUIRED, - user_verification = UserVerificationRequirement.PREFERRED, - ), - exclude_credentials = exclude_creds, - ) + challenge = secrets.token_bytes(WEBAUTHN_CHALLENGE_BYTES) + ... + options = generate_registration_options( + rp_id = self.rp_id, + rp_name = self.rp_name, + user_id = user_id, + user_name = username, + challenge = challenge, + attestation = AttestationConveyancePreference.NONE, + authenticator_selection = AuthenticatorSelectionCriteria( + resident_key = ResidentKeyRequirement.REQUIRED, + user_verification = UserVerificationRequirement.PREFERRED, + ), + exclude_credentials = exclude_creds, + ) ``` **ResidentKeyRequirement.REQUIRED** means the authenticator must store the credential on the device itself (a "discoverable credential" or "passkey"). This allows usernameless login: the user just taps their security key or uses biometrics, and the authenticator presents the credential without the user typing a username. Without REQUIRED, the server would need to provide a credential ID hint during authentication, which means the user must provide their username first. -**Challenge generation** uses `secrets.token_bytes(32)` at `passkey_manager.py:65`. The `secrets` module is Python's CSPRNG interface, equivalent to `/dev/urandom`. The challenge must be unpredictable to prevent replay attacks. The challenge is stored in Redis with a TTL of 600 seconds (`WEBAUTHN_CHALLENGE_TTL_SECONDS = 600` in `config.py:84`), so it expires before an attacker could realistically capture and replay it. +**Challenge generation** uses `secrets.token_bytes(32)` at `backend/app/core/passkey/passkey_manager.py`. The `secrets` module is Python's CSPRNG interface, equivalent to `/dev/urandom`. The challenge must be unpredictable to prevent replay attacks. The challenge is stored in Redis with a TTL of 600 seconds (`WEBAUTHN_CHALLENGE_TTL_SECONDS = 600` in `config.py`), so it expires before an attacker could realistically capture and replay it. -**Attestation verification** at `passkey_manager.py:96-130` calls `verify_registration_response` from py_webauthn. This checks that the authenticator's response was correctly signed, that the RP ID matches, that the origin matches, and that the challenge matches. The `expected_origin = self.rp_origin` check at `passkey_manager.py:109` is critical: it ensures the registration happened from the legitimate frontend, not a phishing page on a different domain. +**Attestation verification** at `backend/app/core/passkey/passkey_manager.py` calls `verify_registration_response` from py_webauthn. This checks that the authenticator's response was correctly signed, that the RP ID matches, that the origin matches, and that the challenge matches. The `expected_origin = self.rp_origin` check at `backend/app/core/passkey/passkey_manager.py` is critical: it ensures the registration happened from the legitimate frontend, not a phishing page on a different domain. ### Authentication Flow -Authentication follows the same challenge-response pattern. The server generates options at `passkey_manager.py:132-160`, sends the challenge to the client, the client's authenticator signs the challenge with its private key, and the server verifies at `passkey_manager.py:162-207`. +Authentication follows the same challenge-response pattern. The server generates options at `backend/app/core/passkey/passkey_manager.py`, sends the challenge to the client, the client's authenticator signs the challenge with its private key, and the server verifies at `backend/app/core/passkey/passkey_manager.py`. **Why challenge-response?** Without a fresh challenge, an attacker who intercepts one authentication response could replay it forever. The challenge ensures each authentication is unique. The authenticator signs the challenge (along with other data like the RP ID and origin) with its private key. The server verifies the signature using the stored public key. Even if the signed response is intercepted, it cannot be reused because the next authentication will have a different challenge. -**Why check origin?** The origin check (`expected_origin = self.rp_origin`) at `passkey_manager.py:177` prevents credential phishing. If an attacker sets up a fake site at `evil-chat.com` and tricks the user into authenticating, the authenticator will include `evil-chat.com` as the origin in the signed data. When the attacker tries to forward that signed response to the real server, the origin check fails because the server expects `http://localhost:3000` (or whatever the production origin is). The authenticator binds the signature to the origin, making phishing structurally impossible. +**Why check origin?** The origin check (`expected_origin = self.rp_origin`) at `backend/app/core/passkey/passkey_manager.py` prevents credential phishing. If an attacker sets up a fake site at `evil-chat.com` and tricks the user into authenticating, the authenticator will include `evil-chat.com` as the origin in the signed data. When the attacker tries to forward that signed response to the real server, the origin check fails because the server expects `http://localhost:3000` (or whatever the production origin is). The authenticator binds the signature to the origin, making phishing structurally impossible. ### Clone Detection -From `passkey_manager.py:184-193`: +From `backend/app/core/passkey/passkey_manager.py`: ```python if (credential_current_sign_count != 0 and new_sign_count != 0 - and new_sign_count <= credential_current_sign_count): - logger.error( - "Signature counter did not increase: current=%s, new=%s. Possible cloned authenticator detected!", - credential_current_sign_count, - new_sign_count - ) - raise ValueError( - "Signature counter anomaly detected - potential cloned authenticator" - ) + and new_sign_count <= credential_current_sign_count): + logger.error( + "Signature counter did not increase: current=%s, new=%s. Possible cloned authenticator detected!", + credential_current_sign_count, + new_sign_count + ) + raise ValueError( + "Signature counter anomaly detected - potential cloned authenticator" + ) ``` **What cloning means.** If someone physically copies a hardware security key (or extracts the private key from a software authenticator), both the original and the clone have the same credential. Each has its own signature counter starting from the same value. When the original authenticates, its counter increments to N+1. When the clone authenticates, its counter also increments, but from N, producing N+1 (or a different value depending on when each was used). If the server sees a counter that is less than or equal to the last recorded counter, it knows two devices are using the same credential. @@ -665,68 +663,68 @@ if (credential_current_sign_count != 0 and new_sign_count != 0 ### Connection Management -From `websocket_manager.py:43-95`: +From `websocket_manager.py`: ```python async def connect(self, websocket, user_id): - await websocket.accept() - if user_id not in self.active_connections: - self.active_connections[user_id] = [] - if len(self.active_connections[user_id]) >= WS_MAX_CONNECTIONS_PER_USER: - await self._send_error(websocket, "max_connections", ...) - await websocket.close() - return False - self.active_connections[user_id].append(websocket) - await presence_service.set_user_online(user_id) - self.heartbeat_tasks[user_id] = asyncio.create_task(self._heartbeat_loop(websocket, user_id)) - await self._subscribe_to_messages(user_id) - return True + await websocket.accept + if user_id not in self.active_connections: + self.active_connections[user_id] = [] + if len(self.active_connections[user_id]) >= WS_MAX_CONNECTIONS_PER_USER: + await self._send_error(websocket, "max_connections", ...) + await websocket.close + return False + self.active_connections[user_id].append(websocket) + await presence_service.set_user_online(user_id) + self.heartbeat_tasks[user_id] = asyncio.create_task(self._heartbeat_loop(websocket, user_id)) + await self._subscribe_to_messages(user_id) + return True ``` **Why per-user connection lists?** A user might have the chat open on their phone and their laptop simultaneously. Each device creates its own WebSocket connection. The `active_connections` dict maps `UUID -> list[WebSocket]`, so when a message arrives for a user, it gets sent to all of their active connections. Without this, multi-device support would not work. -**Why max 5?** `WS_MAX_CONNECTIONS_PER_USER = 5` (from `config.py:142`). Without a limit, a malicious client could open thousands of WebSocket connections and exhaust server memory. Five is generous enough for legitimate multi-device usage (phone, tablet, laptop, desktop, maybe one more) while capping resource consumption. The specific number is configurable via environment variable. +**Why max 5?** `WS_MAX_CONNECTIONS_PER_USER = 5` (from `config.py`). Without a limit, a malicious client could open thousands of WebSocket connections and exhaust server memory. Five is generous enough for legitimate multi-device usage (phone, tablet, laptop, desktop, maybe one more) while capping resource consumption. The specific number is configurable via environment variable. -**Why heartbeat?** WebSocket connections can silently die. A user closes their laptop lid, their WiFi drops, or a NAT gateway times out the connection. The TCP stack might not detect the dead connection for minutes or hours. The heartbeat loop at `websocket_manager.py:177-201` sends a ping every `WS_HEARTBEAT_INTERVAL` (30 seconds by default). If the send fails, it means the connection is dead, and `disconnect` cleans it up. This keeps the `active_connections` dict accurate and ensures presence status reflects reality. +**Why heartbeat?** WebSocket connections can silently die. A user closes their laptop lid, their WiFi drops, or a NAT gateway times out the connection. The TCP stack might not detect the dead connection for minutes or hours. The heartbeat loop at `websocket_manager.py` sends a ping every `WS_HEARTBEAT_INTERVAL` (30 seconds by default). If the send fails, it means the connection is dead, and `disconnect` cleans it up. This keeps the `active_connections` dict accurate and ensures presence status reflects reality. ### Live Query Subscription -From `websocket_manager.py:203-223`: +From `websocket_manager.py`: ```python async def _subscribe_to_messages(self, user_id): - def message_callback(update: LiveMessageUpdate): - asyncio.create_task(self._handle_live_message(user_id, update)) + def message_callback(update: LiveMessageUpdate): + asyncio.create_task(self._handle_live_message(user_id, update)) - live_id = await surreal_db.live_messages_for_user( - user_id = str(user_id), - callback = message_callback - ) - self.live_query_ids[user_id] = live_id + live_id = await surreal_db.live_messages_for_user( + user_id = str(user_id), + callback = message_callback + ) + self.live_query_ids[user_id] = live_id ``` -**SurrealDB pushes new messages to the server, server forwards to client. No polling needed.** SurrealDB has a native live query feature. When you register a live query like `LIVE SELECT * FROM messages WHERE recipient_id = $user_id`, SurrealDB pushes every new matching record to the callback in real time. The server does not need to poll the database on a timer. When a new message is created in SurrealDB (by `message_service.store_encrypted_message`), the live query fires, `_handle_live_message` at `websocket_manager.py:225-251` picks it up, wraps it in a WebSocket message schema, and sends it to all of that user's active connections. +**SurrealDB pushes new messages to the server, server forwards to client. No polling needed.** SurrealDB has a native live query feature. When you register a live query like `LIVE SELECT * FROM messages WHERE recipient_id = $user_id`, SurrealDB pushes every new matching record to the callback in real time. The server does not need to poll the database on a timer. When a new message is created in SurrealDB (by `message_service.store_encrypted_message`), the live query fires, `_handle_live_message` at `websocket_manager.py` picks it up, wraps it in a WebSocket message schema, and sends it to all of that user's active connections. This is more efficient than polling because there is zero latency (the callback fires the instant the record is created) and zero wasted queries (no empty poll cycles when no messages are pending). ### Dead Connection Cleanup -The `disconnect` method at `websocket_manager.py:97-133` handles cleanup: +The `disconnect` method at `websocket_manager.py` handles cleanup: ```python async def disconnect(self, websocket, user_id): - if user_id in self.active_connections: - if websocket in self.active_connections[user_id]: - self.active_connections[user_id].remove(websocket) + if user_id in self.active_connections: + if websocket in self.active_connections[user_id]: + self.active_connections[user_id].remove(websocket) - if not self.active_connections[user_id]: - del self.active_connections[user_id] - await presence_service.set_user_offline(user_id) - # Kill live query - # Cancel heartbeat task + if not self.active_connections[user_id]: + del self.active_connections[user_id] + await presence_service.set_user_offline(user_id) + # Kill live query + # Cancel heartbeat task ``` -Dead connections are also detected during `send_message` at `websocket_manager.py:135-153`. If sending to a WebSocket raises an exception, that WebSocket is added to a `dead_connections` list, and each dead connection is cleaned up via `disconnect` after the send loop completes. This lazy cleanup means the system self-heals: even if a heartbeat fails to detect a dead connection, the next message send attempt will catch it. +Dead connections are also detected during `send_message` at `websocket_manager.py`. If sending to a WebSocket raises an exception, that WebSocket is added to a `dead_connections` list, and each dead connection is cleaned up via `disconnect` after the send loop completes. This lazy cleanup means the system self-heals: even if a heartbeat fails to detect a dead connection, the next message send attempt will catch it. The cleanup only sets the user offline and kills the live query when the last connection for that user is removed. If they still have another tab open, their other connection keeps working and they stay online. @@ -736,24 +734,24 @@ The cleanup only sets the user offline and kills the live query when the last co ### Server-Side Storage (Passthrough) -From `message_service.py:269-314`: +From `backend/app/services/message_service.py`: ```python async def store_encrypted_message( - self, session, sender_id, recipient_id, ciphertext, nonce, header, room_id + self, session, sender_id, recipient_id, ciphertext, nonce, header, room_id ): - ... - surreal_message = { - "sender_id": str(sender_id), - "recipient_id": str(recipient_id), - "room_id": room_id, - "ciphertext": ciphertext, - "nonce": nonce, - "header": header, - "sender_username": sender_user.username, - ... - } - result = await surreal_db.create_message(surreal_message) + ... + surreal_message = { + "sender_id": str(sender_id), + "recipient_id": str(recipient_id), + "room_id": room_id, + "ciphertext": ciphertext, + "nonce": nonce, + "header": header, + "sender_username": sender_user.username, + ... + } + result = await surreal_db.create_message(surreal_message) ``` The server stores encrypted data without any decryption capability. The `ciphertext`, `nonce`, and `header` fields are base64-encoded strings that arrive from the client and get stored as-is. The server never sees the plaintext. The server never sees the encryption keys. The server could be fully compromised and the attacker still could not read messages, because the message keys exist only on the clients' devices in IndexedDB. @@ -762,30 +760,30 @@ This is the correct E2E approach. Many "encrypted" chat systems encrypt on the s ### Conversation Initialization -From `message_service.py:48-166`: +From `backend/app/services/message_service.py`: ```python async def initialize_conversation(self, session, sender_id, recipient_id): - # Check for existing ratchet state - # Fetch sender's identity key - # Fetch recipient's prekey bundle - # Perform X3DH sender-side - 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 - ) - # Initialize Double Ratchet with shared key - dr_state = double_ratchet.initialize_sender( - shared_key = x3dh_result.shared_key, - peer_public_key = recipient_spk_public_bytes - ) - # Serialize and persist ratchet state to PostgreSQL + # Check for existing ratchet state + # Fetch sender's identity key + # Fetch recipient's prekey bundle + # Perform X3DH sender-side + x3dh_result = initiateX3DH (frontend/src/crypto/x3dh.ts)( + alice_identity_private_x25519 = sender_ik.private_key, + bob_bundle = recipient_bundle, + bob_identity_public_ed25519 = recipient_ik.public_key_ed25519 + ) + # Initialize Double Ratchet with shared key + dr_state = double_ratchet.initialize_sender( + shared_key = x3dh_result.shared_key, + peer_public_key = recipient_spk_public_bytes + ) + # Serialize and persist ratchet state to PostgreSQL ``` This is the bridge between key exchange and ongoing encryption. The method orchestrates the entire flow: check if a conversation already exists (idempotency), fetch both users' keys from PostgreSQL, perform the X3DH exchange, initialize the Double Ratchet with the resulting shared key, serialize the ratchet state, and persist it. After this method completes, `encrypt_message` and `decrypt_message` can operate using the stored ratchet state. -The check at `message_service.py:60-73` is important: if a ratchet state already exists for this sender-recipient pair, the method returns it without doing anything. This prevents accidental re-initialization, which would generate a new shared secret and break the existing conversation. +The check at `backend/app/services/message_service.py` is important: if a ratchet state already exists for this sender-recipient pair, the method returns it without doing anything. This prevents accidental re-initialization, which would generate a new shared secret and break the existing conversation. --- @@ -793,16 +791,16 @@ The check at `message_service.py:60-73` is important: if a ratchet state already ### Constant-Time Comparison -From `primitives.ts:388-397`: +From `frontend/src/crypto/primitives.ts`: ```typescript export function constantTimeEqual(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 + 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 } ``` @@ -814,40 +812,40 @@ The length check at the top does leak whether the lengths are equal. This is gen ### Random Number Generation -On the Python backend, `os.urandom(AES_GCM_NONCE_SIZE)` at `double_ratchet.py:122` reads from the operating system's cryptographically secure random number generator (CSPRNG). On Linux, this is backed by `/dev/urandom`, which draws entropy from hardware events (disk timing, network interrupts, etc.) and maintains a CSPRNG state that is computationally indistinguishable from true randomness. +On the Python backend, `os.urandom(AES_GCM_NONCE_SIZE)` at `frontend/src/crypto/double-ratchet.ts` reads from the operating system's cryptographically secure random number generator (CSPRNG). On Linux, this is backed by `/dev/urandom`, which draws entropy from hardware events (disk timing, network interrupts, etc.) and maintains a CSPRNG state that is computationally indistinguishable from true randomness. -On the browser frontend, the `generateRandomBytes` function at `primitives.ts:294-298`: +On the browser frontend, the `generateRandomBytes` function at `frontend/src/crypto/primitives.ts`: ```typescript export function generateRandomBytes(length: number): Uint8Array { - const bytes = new Uint8Array(length) - crypto.getRandomValues(bytes) - return bytes + const bytes = new Uint8Array(length) + crypto.getRandomValues(bytes) + return bytes } ``` This calls the WebCrypto API's CSPRNG. Every major browser implements this using the OS CSPRNG under the hood. Chrome uses BoringSSL's CSPRNG, Firefox uses NSS, Safari uses CommonCrypto. All ultimately draw from the kernel's entropy pool. -The same function is used for nonce generation in `aesGcmEncrypt` at `primitives.ts:243`: `const nonce = generateRandomBytes(AES_GCM_NONCE_SIZE)`. It is also used for WebAuthn challenge generation on the server side via `secrets.token_bytes(WEBAUTHN_CHALLENGE_BYTES)` at `passkey_manager.py:65`, which is Python's wrapper around `os.urandom`. +The same function is used for nonce generation in `aesGcmEncrypt` at `frontend/src/crypto/primitives.ts`: `const nonce = generateRandomBytes(AES_GCM_NONCE_SIZE)`. It is also used for WebAuthn challenge generation on the server side via `secrets.token_bytes(WEBAUTHN_CHALLENGE_BYTES)` at `backend/app/core/passkey/passkey_manager.py`, which is Python's wrapper around `os.urandom`. **Why CSPRNG?** Nonces, challenges, and ephemeral keys must be unpredictable. If an attacker can predict the next nonce, they can precompute the keystream for AES-GCM and decrypt messages in real time. If they can predict the next ephemeral key, they can compute the DH shared secret. If they can predict the next WebAuthn challenge, they can pre-sign a response and replay it. -**Why not Math.random()?** `Math.random()` uses a PRNG (pseudorandom number generator) seeded from a low-entropy source (often just the current time or a simple counter). Most JavaScript engines use xorshift128+ or a similar fast PRNG. The internal state is typically 128 bits, and it can be reconstructed by observing as few as 4-5 consecutive outputs. An attacker who knows the internal state of `Math.random()` can predict every future output, which would compromise every nonce and every ephemeral key generated by the application. In 2015, researchers demonstrated recovering the full xorshift128+ state from V8's `Math.random()` by observing just a few outputs. `Math.random()` is fine for shuffling a deck of cards in a UI animation. It is catastrophic for cryptography. +**Why not Math.random?** `Math.random` uses a PRNG (pseudorandom number generator) seeded from a low-entropy source (often just the current time or a simple counter). Most JavaScript engines use xorshift128+ or a similar fast PRNG. The internal state is typically 128 bits, and it can be reconstructed by observing as few as 4-5 consecutive outputs. An attacker who knows the internal state of `Math.random` can predict every future output, which would compromise every nonce and every ephemeral key generated by the application. In 2015, researchers demonstrated recovering the full xorshift128+ state from V8's `Math.random` by observing just a few outputs. `Math.random` is fine for shuffling a deck of cards in a UI animation. It is catastrophic for cryptography. ### Key Serialization -All keys are serialized as base64url-encoded raw bytes. For example, in `x3dh_manager.py:67-85`: +All keys are serialized as base64url-encoded raw bytes. For example, in `frontend/src/crypto/x3dh.ts`: ```python private_bytes = private_key.private_bytes( - encoding = serialization.Encoding.Raw, - format = serialization.PrivateFormat.Raw, - encryption_algorithm = serialization.NoEncryption() + encoding = serialization.Encoding.Raw, + format = serialization.PrivateFormat.Raw, + encryption_algorithm = serialization.NoEncryption ) ... return ( - bytes_to_base64url(private_bytes), - bytes_to_base64url(public_bytes) + bytes_to_base64url(private_bytes), + bytes_to_base64url(public_bytes) ) ``` @@ -863,53 +861,53 @@ Here is what happens when Alice types "Hello Bob" and it reaches Bob's screen. E **1. Alice types "Hello Bob" in Chat.tsx.** The `ChatInput` component captures the text in a form submit handler. The handler creates a temporary message ID (a random string for optimistic UI updates) and calls into the message sending logic. The plaintext exists only in the browser's JavaScript heap at this point. -**2. crypto-service.ts `encrypt("bob-uuid", "Hello Bob")`.** The `CryptoService.encrypt` method at `crypto-service.ts:217-249` is invoked. It first checks whether a ratchet session exists with Bob. +**2. crypto-service.ts `encrypt("bob-uuid", "Hello Bob")`.** The `CryptoService.encrypt` method at `frontend/src/crypto/crypto-service.ts` is invoked. It first checks whether a ratchet session exists with Bob. -**3. Load ratchet state from IndexedDB.** `getRatchetState(peerId)` at `crypto-service.ts:303-315` checks the in-memory `Map` first (keyed by peer ID). If the state is not in memory, it queries IndexedDB via `key-store.ts`, deserializes the stored JSON back into a `DoubleRatchetState` object (reconstructing CryptoKey objects from base64-encoded bytes), and caches it. If no state exists at all, `establishSession(peerId)` triggers the full X3DH flow: fetch Bob's prekey bundle from the server via `api.encryption.getPrekeyBundle`, perform client-side X3DH via `x3dh.ts:initiateX3DH`, and initialize the sending ratchet via `double-ratchet.ts:initializeRatchetSender`. +**3. Load ratchet state from IndexedDB.** `getRatchetState(peerId)` at `frontend/src/crypto/crypto-service.ts` checks the in-memory `Map` first (keyed by peer ID). If the state is not in memory, it queries IndexedDB via `key-store.ts`, deserializes the stored JSON back into a `DoubleRatchetState` object (reconstructing CryptoKey objects from base64-encoded bytes), and caches it. If no state exists at all, `establishSession(peerId)` triggers the full X3DH flow: fetch Bob's prekey bundle from the server via `api.encryption.getPrekeyBundle`, perform client-side X3DH via `x3dh.ts:initiateX3DH`, and initialize the sending ratchet via `double-ratchet.ts:initializeRatchetSender`. -**4. double-ratchet.ts `encryptMessage`: advance chain key via HMAC.** At `double-ratchet.ts:167`, `deriveMessageKey(state.sending_chain_key)` computes two values: - - `messageKey = HMAC-SHA-256(chainKey, "MessageKey")` - the key that will encrypt this specific message - - `nextChainKey = HMAC-SHA-256(chainKey, "ChainKey")` - the chain key for the next message +**4. double-ratchet.ts `encryptMessage`: advance chain key via HMAC.** At `double-ratchet.ts`, `deriveMessageKey(state.sending_chain_key)` computes two values: + - `messageKey = HMAC-SHA-256(chainKey, "MessageKey")` - the key that will encrypt this specific message + - `nextChainKey = HMAC-SHA-256(chainKey, "ChainKey")` - the chain key for the next message The old `sending_chain_key` is immediately overwritten with `nextChainKey`. The `messageKey` is used once and never stored. This is the forward secrecy mechanism: after encryption, the message key cannot be recovered from the updated chain state. -**5. primitives.ts `aesGcmEncrypt` with message key.** At `double-ratchet.ts:178`, the 32-byte `messageKey` is imported as an AES-GCM CryptoKey. The `plaintext` bytes (`TextEncoder.encode("Hello Bob")` = 9 bytes) are encrypted. A 12-byte random nonce is generated via `crypto.getRandomValues`. The message header (DH public key, message number, previous chain length) is serialized to JSON and used as Additional Authenticated Data (AAD). AES-GCM produces the ciphertext (9 bytes of encrypted data + 16 bytes of authentication tag = 25 bytes total). +**5. primitives.ts `aesGcmEncrypt` with message key.** At `double-ratchet.ts`, the 32-byte `messageKey` is imported as an AES-GCM CryptoKey. The `plaintext` bytes (`TextEncoder.encode("Hello Bob")` = 9 bytes) are encrypted. A 12-byte random nonce is generated via `crypto.getRandomValues`. The message header (DH public key, message number, previous chain length) is serialized to JSON and used as Additional Authenticated Data (AAD). AES-GCM produces the ciphertext (9 bytes of encrypted data + 16 bytes of authentication tag = 25 bytes total). -**6. Return {ciphertext, nonce, header}.** The `EncryptedMessage` object at `double-ratchet.ts:183-187` contains raw `Uint8Array` ciphertext and nonce, plus a typed `MessageHeader` object. Back in `crypto-service.ts:244-248`, the ciphertext and nonce are base64-encoded to strings, and the header (including any pending X3DH header for first messages) is JSON-stringified into a single string. The result is three strings: `ciphertext`, `nonce`, and `header`. All three are opaque to anyone without the message key. +**6. Return {ciphertext, nonce, header}.** The `EncryptedMessage` object at `double-ratchet.ts` contains raw `Uint8Array` ciphertext and nonce, plus a typed `MessageHeader` object. Back in `frontend/src/crypto/crypto-service.ts`, the ciphertext and nonce are base64-encoded to strings, and the header (including any pending X3DH header for first messages) is JSON-stringified into a single string. The result is three strings: `ciphertext`, `nonce`, and `header`. All three are opaque to anyone without the message key. -**7. WebSocket sends JSON payload.** `websocket-manager.ts:108-124` calls `sendEncryptedMessage`, which constructs the outgoing message: +**7. WebSocket sends JSON payload.** `websocket-manager.ts` calls `sendEncryptedMessage`, which constructs the outgoing message: ```json { - "type": "encrypted_message", - "recipient_id": "bob-uuid", - "room_id": "room-uuid", - "ciphertext": "base64...", - "nonce": "base64...", - "header": "{\"ratchet\":{\"dh_public_key\":\"...\",\"message_number\":0,...}}", - "temp_id": "temp-abc123" + "type": "encrypted_message", + "recipient_id": "bob-uuid", + "room_id": "room-uuid", + "ciphertext": "base64...", + "nonce": "base64...", + "header": "{\"ratchet\":{\"dh_public_key\":\"...\",\"message_number\":0,...}}", + "temp_id": "temp-abc123" } ``` This JSON is sent via `ws.send(JSON.stringify(message))`. If the WebSocket is not connected, the message is queued in `messageQueue` and sent when the connection is restored. -**8. websocket.py:46-54 receives.** The `websocket_endpoint` at `websocket.py:46` calls `data = await websocket.receive_text()`, parses the JSON with `json.loads(data)`, and passes the resulting dict to `websocket_service.route_message`. At this point the server has the encrypted blob. It cannot decrypt it. It does not have the message key, the chain key, or any ratchet state for Alice and Bob's conversation (in the E2E model). +**8. websocket.py receives.** The `websocket_endpoint` at `websocket.py` calls `data = await websocket.receive_text`, parses the JSON with `json.loads(data)`, and passes the resulting dict to `websocket_service.route_message`. At this point the server has the encrypted blob. It cannot decrypt it. It does not have the message key, the chain key, or any ratchet state for Alice and Bob's conversation (in the E2E model). -**9. websocket_service.py routes to handler.** At `websocket_service.py:63`, the message type `"encrypted_message"` dispatches to `handle_encrypted_message` at `websocket_service.py:87-179`. The handler extracts `recipient_id`, `room_id`, `ciphertext`, `nonce`, and `header` from the message dict. +**9. websocket_service.py routes to handler.** At `backend/app/services/websocket_service.py`, the message type `"encrypted_message"` dispatches to `handle_encrypted_message` at `backend/app/services/websocket_service.py`. The handler extracts `recipient_id`, `room_id`, `ciphertext`, `nonce`, and `header` from the message dict. -**10. message_service.py:269-314 stores in SurrealDB.** The handler calls `message_service.store_encrypted_message`, which constructs a SurrealDB document: +**10. backend/app/services/message_service.py stores in SurrealDB.** The handler calls `message_service.store_encrypted_message`, which constructs a SurrealDB document: ```python surreal_message = { - "sender_id": str(sender_id), - "recipient_id": str(recipient_id), - "room_id": room_id, - "ciphertext": ciphertext, # still base64, still encrypted - "nonce": nonce, # still base64 - "header": header, # still JSON string - "sender_username": sender_user.username, - "created_at": now.isoformat(), - "updated_at": now.isoformat(), + "sender_id": str(sender_id), + "recipient_id": str(recipient_id), + "room_id": room_id, + "ciphertext": ciphertext, # still base64, still encrypted + "nonce": nonce, # still base64 + "header": header, # still JSON string + "sender_username": sender_user.username, + "created_at": now.isoformat, + "updated_at": now.isoformat, } ``` @@ -917,38 +915,38 @@ The document is written to SurrealDB via `surreal_db.create_message(surreal_mess **11. SurrealDB live query fires.** The instant the message record is created, SurrealDB pushes a `CREATE` event to any registered live query matching `recipient_id = bob_uuid`. This is a push, not a pull. There is no polling interval. The latency from write to callback is measured in single-digit milliseconds. -**12. websocket_manager.py:225-251 picks up the update.** The `_handle_live_message` callback at `websocket_manager.py:225` receives the `LiveMessageUpdate`. It checks that `update.action == "CREATE"` (ignoring updates and deletes). It wraps the message data in an `EncryptedMessageWS` Pydantic schema and calls `self.send_message(user_id, ws_message.model_dump(mode="json"))`. +**12. websocket_manager.py picks up the update.** The `_handle_live_message` callback at `websocket_manager.py` receives the `LiveMessageUpdate`. It checks that `update.action == "CREATE"` (ignoring updates and deletes). It wraps the message data in an `EncryptedMessageWS` Pydantic schema and calls `self.send_message(user_id, ws_message.model_dump(mode="json"))`. -**13. ConnectionManager sends to Bob's WebSocket(s).** At `websocket_manager.py:135-153`, the manager iterates over all of Bob's active connections (he might have the app open on his phone and laptop) and calls `websocket.send_json(message)` on each. If any send fails, that connection is added to a `dead_connections` list and cleaned up after the loop. +**13. ConnectionManager sends to Bob's WebSocket(s).** At `websocket_manager.py`, the manager iterates over all of Bob's active connections (he might have the app open on his phone and laptop) and calls `websocket.send_json(message)` on each. If any send fails, that connection is added to a `dead_connections` list and cleaned up after the loop. -**14. Bob's websocket-manager.ts receives.** The `handleMessage` method at `websocket-manager.ts:166-178` parses the JSON from `event.data`. The `routeMessage` method at `websocket-manager.ts:180-201` runs through type guards. `isEncryptedMessageWS(message)` matches because the message has `type: "encrypted_message"` and the required fields. The message is dispatched to `handleWSMessage` in `message-handlers.ts`. +**14. Bob's websocket-manager.ts receives.** The `handleMessage` method at `websocket-manager.ts` parses the JSON from `event.data`. The `routeMessage` method at `websocket-manager.ts` runs through type guards. `isEncryptedMessageWS(message)` matches because the message has `type: "encrypted_message"` and the required fields. The message is dispatched to `handleWSMessage` in `message-handlers.ts`. -**15. crypto-service.ts `decrypt(alice_id, ciphertext, nonce, header)`.** The message handler calls `CryptoService.decrypt` at `crypto-service.ts:251-301`. The `header` string is parsed to extract the ratchet header (DH public key, message number, previous chain length) and optionally an X3DH header (if this is the first message in a new conversation). +**15. crypto-service.ts `decrypt(alice_id, ciphertext, nonce, header)`.** The message handler calls `CryptoService.decrypt` at `frontend/src/crypto/crypto-service.ts`. The `header` string is parsed to extract the ratchet header (DH public key, message number, previous chain length) and optionally an X3DH header (if this is the first message in a new conversation). -**16. Load ratchet state from IndexedDB.** Same mechanism as step 3, but for Bob's side. If no ratchet state exists for Alice, and the message includes an X3DH header, `handleIncomingSession` at `crypto-service.ts:168-215` triggers: - - Look up Alice's identity key from the X3DH header - - Look up Bob's signed prekey private key from IndexedDB - - Look up the consumed one-time prekey if one was used - - Call `receiveX3DH` from `x3dh.ts:175-215` to compute the same shared secret Alice computed - - Call `initializeRatchetReceiver` from `double-ratchet.ts:68-88` with the shared secret and Bob's signed prekey pair +**16. Load ratchet state from IndexedDB.** Same mechanism as step 3, but for Bob's side. If no ratchet state exists for Alice, and the message includes an X3DH header, `handleIncomingSession` at `frontend/src/crypto/crypto-service.ts` triggers: + - Look up Alice's identity key from the X3DH header + - Look up Bob's signed prekey private key from IndexedDB + - Look up the consumed one-time prekey if one was used + - Call `receiveX3DH` from `x3dh.ts` to compute the same shared secret Alice computed + - Call `initializeRatchetReceiver` from `double-ratchet.ts` with the shared secret and Bob's signed prekey pair -**17. Check skipped keys, advance receiving chain.** `decryptMessage` at `double-ratchet.ts:190-238` runs through the three-case logic: - - Check if this message's key was previously stored (skipped key cache hit) - - Check if Alice's DH public key in the header differs from the last known key (DH ratchet step needed) - - Check if the message number is ahead of the current position (skip intermediate keys) - Then derive the message key: `messageKey = HMAC-SHA-256(receivingChainKey, "MessageKey")`. +**17. Check skipped keys, advance receiving chain.** `decryptMessage` at `double-ratchet.ts` runs through the three-case logic: + - Check if this message's key was previously stored (skipped key cache hit) + - Check if Alice's DH public key in the header differs from the last known key (DH ratchet step needed) + - Check if the message number is ahead of the current position (skip intermediate keys) + Then derive the message key: `messageKey = HMAC-SHA-256(receivingChainKey, "MessageKey")`. -**18. primitives.ts `aesGcmDecrypt`.** At `double-ratchet.ts:232`, AES-256-GCM decryption is performed. The 32-byte message key is imported as a CryptoKey. The 12-byte nonce from the message is used as the IV. The header JSON is reconstructed and used as AAD. `subtle.decrypt` verifies the 16-byte authentication tag and produces the 9 plaintext bytes. If the tag does not match (tampered message, wrong key, wrong nonce, wrong AAD), the WebCrypto API throws an `OperationError`. +**18. primitives.ts `aesGcmDecrypt`.** At `double-ratchet.ts`, AES-256-GCM decryption is performed. The 32-byte message key is imported as a CryptoKey. The 12-byte nonce from the message is used as the IV. The header JSON is reconstructed and used as AAD. `subtle.decrypt` verifies the 16-byte authentication tag and produces the 9 plaintext bytes. If the tag does not match (tampered message, wrong key, wrong nonce, wrong AAD), the WebCrypto API throws an `OperationError`. -**19. Return "Hello Bob".** The `Uint8Array` plaintext bytes are decoded via `new TextDecoder().decode(plaintextBytes)` at `crypto-service.ts:300`, producing the string `"Hello Bob"`. The ratchet state is serialized and saved back to IndexedDB. +**19. Return "Hello Bob".** The `Uint8Array` plaintext bytes are decoded via `new TextDecoder.decode(plaintextBytes)` at `frontend/src/crypto/crypto-service.ts`, producing the string `"Hello Bob"`. The ratchet state is serialized and saved back to IndexedDB. **20. Chat.tsx renders plaintext.** The decrypted message is added to the message store. The reactive SolidJS component re-renders the message list. Bob sees "Hello Bob" on screen. The entire journey from Alice's keypress to Bob's screen took: - - ~1ms for client-side encryption (HMAC + AES-GCM) - - ~10-50ms for WebSocket round trip (depends on network) - - ~1ms for SurrealDB write + live query fire - - ~10-50ms for WebSocket delivery to Bob - - ~1ms for client-side decryption - Total: roughly 25-100ms end to end. + - ~1ms for client-side encryption (HMAC + AES-GCM) + - ~10-50ms for WebSocket round trip (depends on network) + - ~1ms for SurrealDB write + live query fire + - ~10-50ms for WebSocket delivery to Bob + - ~1ms for client-side decryption + Total: roughly 25-100ms end to end. The plaintext "Hello Bob" existed in memory on Alice's device and Bob's device. At no other point in the pipeline, not on the WebSocket server, not in SurrealDB, not in PostgreSQL, not in Redis, did the plaintext exist. That is what end-to-end encryption means in practice. @@ -958,23 +956,23 @@ The plaintext "Hello Bob" existed in memory on Alice's device and Bob's device. ### Encryption Failures -When encryption fails (corrupted ratchet state, invalid key, etc.), an `EncryptionError` is raised at `message_service.py:366`. The exception hierarchy in `exceptions.py:73-76` derives from `AppException`. The exception handler returns the error to the client via the WebSocket error message type. The message is never stored, because the encryption failed before the store call. +When encryption fails (corrupted ratchet state, invalid key, etc.), an `EncryptionError` is raised at `backend/app/services/message_service.py`. The exception hierarchy in `exceptions.py` derives from `AppException`. The exception handler returns the error to the client via the WebSocket error message type. The message is never stored, because the encryption failed before the store call. ### Database Transaction Failures -`IntegrityError` is caught in every service method that writes to the database. For example, at `message_service.py:161-164`: +`IntegrityError` is caught in every service method that writes to the database. For example, at `backend/app/services/message_service.py`: ```python except IntegrityError as e: - await session.rollback() - raise DatabaseError("Failed to initialize conversation") from e + await session.rollback + raise DatabaseError("Failed to initialize conversation") from e ``` The pattern is consistent: catch the SQLAlchemy integrity error, roll back the session to a clean state, then raise a custom `DatabaseError`. This ensures the database is never left in a partially committed state. The `from e` chain preserves the original exception for logging. ### WebSocket Disconnection -When a WebSocket disconnects (user closes the tab, network drops), `WebSocketDisconnect` is caught at `websocket.py:79`. The `finally` block at `websocket.py:84` always runs `connection_manager.disconnect(websocket, user_uuid)`, which: +When a WebSocket disconnects (user closes the tab, network drops), `WebSocketDisconnect` is caught at `websocket.py`. The `finally` block at `websocket.py` always runs `connection_manager.disconnect(websocket, user_uuid)`, which: - Removes the WebSocket from the connection pool - If it was the last connection for that user: kills the SurrealDB live query, cancels the heartbeat task, and sets presence to offline via Redis - If other connections remain: just removes this one and logs the remaining count @@ -985,7 +983,7 @@ When a WebSocket disconnects (user closes the tab, network drops), `WebSocketDis ### Unit Tests -**Test X3DH key agreement.** Generate identity keys for Alice and Bob. Generate a signed prekey for Bob. Generate an OPK for Bob. Assemble Bob's prekey bundle. Perform sender-side X3DH as Alice via `x3dh_manager.perform_x3dh_sender`. Perform receiver-side X3DH as Bob via `x3dh_manager.perform_x3dh_receiver`, passing Alice's ephemeral public key and identity key from the sender result. Assert that `alice_result.shared_key == bob_result.shared_key`. This verifies the commutative DH math. Also assert that `alice_result.associated_data == bob_result.associated_data`, confirming both sides compute the same AAD. +**Test X3DH key agreement.** Generate identity keys for Alice and Bob. Generate a signed prekey for Bob. Generate an OPK for Bob. Assemble Bob's prekey bundle. Perform sender-side X3DH as Alice via `initiateX3DH (frontend/src/crypto/x3dh.ts)`. Perform receiver-side X3DH as Bob via `receiveX3DH (frontend/src/crypto/x3dh.ts)`, passing Alice's ephemeral public key and identity key from the sender result. Assert that `alice_result.shared_key == bob_result.shared_key`. This verifies the commutative DH math. Also assert that `alice_result.associated_data == bob_result.associated_data`, confirming both sides compute the same AAD. Test the OPK-absent case separately: set `bob_bundle.one_time_prekey = None`, pass `bob_one_time_prekey_private = None` on the receiver side. Verify the shared keys still match (they will, because both sides fall back to the 3-DH variant). @@ -1009,15 +1007,15 @@ For clone detection, call `verify_authentication` with `credential_current_sign_ ### Integration Tests -**Full message flow.** Register two users with WebAuthn mocks. Initialize encryption keys for both via `prekey_service.initialize_user_keys`. Fetch user B's prekey bundle. Initialize a conversation from A to B via `message_service.initialize_conversation`. Send an encrypted message from A. On user B's side, load the ratchet state (performing receiver-side X3DH), and decrypt the message. Verify the plaintext matches. This exercises the entire pipeline: key generation, prekey bundle assembly, X3DH, Double Ratchet initialization, encryption, SurrealDB storage, and decryption. +**Full message flow (frontend).** Stand up two clean clients in `vitest`, run `generateIdentityKeyPair` / `generateSignedPreKey` / `generateOneTimePreKeys` for each, hand Alice Bob's bundle, run `initiateX3DH` and `initializeRatchetSender`, encrypt a message, then on Bob's side run `receiveX3DH` + `initializeRatchetReceiver` and `decryptMessage`. Assert the plaintext matches. The existing `frontend/src/crypto/x3dh.test.ts` and `frontend/src/crypto/double-ratchet.test.ts` already cover this end-to-end. -**WebSocket lifecycle.** Start the FastAPI test server. Connect two test WebSocket clients (simulating Alice and Bob). Send a typed message from Alice via `ws.send_json({"type": "encrypted_message", ...})`. Assert Bob's WebSocket receives the forwarded message. Send a typing indicator from Alice. Assert Bob receives it. Disconnect Alice. Assert Bob receives a presence update (or verify via the presence service that Alice's status is now offline). Reconnect Alice. Verify the heartbeat starts and presence is restored. +**WebSocket lifecycle.** Spin up the FastAPI test app, register two users via the auth flow (which sets a session cookie), open two WebSocket connections carrying the cookies, and exercise the full message round-trip. The cookie-authenticated WebSocket is the only auth path — there is no `?user_id=` query string anymore. -**Database rollback on failure.** Create an async session. Start an operation that will fail (e.g., insert a `RatchetState` with a `user_id` that violates a foreign key constraint). Assert that the session rolls back cleanly and raises `DatabaseError`. Verify that no partial data was committed by querying the table afterward. +**Database rollback on failure.** Create an async session. Start an operation that will fail (for example, inserting an `IdentityKey` row whose `user_id` doesn't exist). Assert that the session rolls back cleanly and raises `DatabaseError`. Verify that no partial data was committed by querying the table afterward. -**Key rotation.** Initialize keys for a user. Verify they have an active signed prekey. Call `prekey_service.rotate_signed_prekey`. Verify the old SPK is now inactive. Verify the new SPK is active. Fetch the prekey bundle. Verify the bundle contains the new SPK, not the old one. Verify the signature on the new SPK validates against the user's Ed25519 identity key. +**Key rotation.** Generate a fresh signed prekey on the client (`generateSignedPreKey`), call `uploadKeys` again with it, then re-fetch the bundle as a peer. Verify the bundle contains the new SPK and that its signature validates against the user's Ed25519 identity key. Confirm that the previously-active SPK row is now `is_active = False` in PostgreSQL. -**OPK exhaustion.** Initialize a user with 2 OPKs. Fetch the prekey bundle twice (consuming both OPKs). Fetch a third time. Verify the bundle is returned with `one_time_prekey = None`. Verify X3DH still succeeds without the OPK (the 3-DH fallback). +**OPK exhaustion.** Upload a user with 2 OPKs. Fetch their prekey bundle twice (consuming both, marking them `is_used = True`). Fetch a third time and verify the bundle returns with `one_time_prekey = None`. Verify X3DH still succeeds without the OPK (the 3-DH fallback). --- @@ -1029,7 +1027,7 @@ For clone detection, call `verify_authentication` with `credential_current_sign_ **Cause:** Reusing the same nonce with the same key completely breaks GCM's security. AES-GCM uses the nonce to generate a unique keystream via CTR mode. If two messages share the same nonce and key, XORing the two ciphertexts cancels out the keystream and produces the XOR of the two plaintexts. An attacker can then use frequency analysis or known-plaintext attacks to recover both messages. Additionally, the authentication tag becomes forgeable. -**Fix:** Always use `os.urandom(12)` for a fresh nonce, as done at `double_ratchet.py:122`. Since each message key is derived from a unique chain key position, nonce reuse across different message keys is not a concern. The risk would only arise if the same message key were used twice, which the chain advancement prevents. +**Fix:** Always use `os.urandom(12)` for a fresh nonce, as done at `frontend/src/crypto/double-ratchet.ts`. Since each message key is derived from a unique chain key position, nonce reuse across different message keys is not a concern. The risk would only arise if the same message key were used twice, which the chain advancement prevents. ### Pitfall 2: Not Verifying Signed Prekey @@ -1037,13 +1035,13 @@ For clone detection, call `verify_authentication` with `credential_current_sign_ **Cause:** Skipping signature verification allows an attacker to substitute their own SPK into Bob's prekey bundle. Alice computes a shared secret with the attacker. The attacker independently computes a shared secret with Bob using Bob's real SPK. The attacker can now read and modify all messages. -**Fix:** Always verify before exchange, as done at `x3dh_manager.py:217-220`: +**Fix:** Always verify before exchange, as done at `frontend/src/crypto/x3dh.ts`: ```python 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") + bob_bundle.signed_prekey_signature, + bob_identity_public_ed25519): + raise ValueError("Invalid signed prekey signature") ``` ### Pitfall 3: Storing Private Keys on Server @@ -1052,7 +1050,7 @@ if not self.verify_signed_prekey(bob_bundle.signed_prekey, **Cause:** If private key material is accessible to the server, anyone who compromises the server (SQL injection, SSH access, backup leak) can decrypt every message ever sent. -**Fix:** Client-side key generation and IndexedDB storage. The server only stores public keys. Look at `prekey_service.py:45-150`: the `store_client_keys` method receives only public keys from the client. The `private_key` fields on `IdentityKey` and `SignedPrekey` models are set to empty strings `""` when storing client-uploaded keys. Private keys live exclusively in the browser's IndexedDB, which is sandboxed per origin. +**Fix:** Client-side key generation and IndexedDB storage. The server only stores public keys. Look at `backend/app/services/prekey_service.py`: the `store_client_keys` method receives only public keys from the client. The `private_key` fields on `IdentityKey` and `SignedPrekey` models are set to empty strings `""` when storing client-uploaded keys. Private keys live exclusively in the browser's IndexedDB, which is sandboxed per origin. ### Pitfall 4: Non-Constant-Time Comparison @@ -1060,16 +1058,16 @@ if not self.verify_signed_prekey(bob_bundle.signed_prekey, **Cause:** Using `===` or `==` for byte array comparison in JavaScript short-circuits on the first mismatch. An attacker who can measure response times with sufficient precision (microsecond resolution is achievable over a network) can determine how many leading bytes are correct and brute-force the rest. -**Fix:** XOR-based comparison as in `primitives.ts:388-397`: +**Fix:** XOR-based comparison as in `frontend/src/crypto/primitives.ts`: ```typescript export function constantTimeEqual(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 + 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 } ``` @@ -1079,19 +1077,19 @@ export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { The codebase follows a strict layering that separates concerns and minimizes the blast radius of bugs. -- **Encryption logic in `core/encryption/`** (never in the API layer). The `x3dh_manager.py` and `double_ratchet.py` files have zero knowledge of HTTP, WebSocket, or databases. They take bytes in and return bytes out. `X3DHManager` does not import SQLModel. `DoubleRatchet` does not import FastAPI. This makes them independently testable: you can instantiate `X3DHManager()` in a test file, call `generate_identity_keypair_x25519()`, and verify the output without setting up a database, a Redis connection, or a web server. It also makes them reusable: if you wanted to use the same X3DH implementation in a CLI tool or a different server framework, you could import `core/encryption/` without pulling in any web dependencies. +- **Encryption logic in `frontend/src/crypto/`** (never in the API layer). The `frontend/src/crypto/x3dh.ts` and `frontend/src/crypto/double-ratchet.ts` files have zero knowledge of HTTP, WebSocket, or databases. They take bytes in and return bytes out. `X3DHManager` does not import SQLModel. `DoubleRatchet` does not import FastAPI. This makes them independently testable: you can instantiate `X3DHManager` in a test file, call `generate_identity_keypair_x25519`, and verify the output without setting up a database, a Redis connection, or a web server. It also makes them reusable: if you wanted to use the same X3DH implementation in a CLI tool or a different server framework, you could import `frontend/src/crypto/` without pulling in any web dependencies. -- **Database operations in `services/`** (never in core). The `prekey_service.py`, `message_service.py`, and `auth_service.py` files handle all SQLModel queries and SurrealDB operations. They receive an `AsyncSession` as a parameter (dependency injection) and call into `core/encryption/` for crypto operations. The crypto layer never sees a session object, and the service layer never calls OpenSSL directly. This means a database schema change only affects service files and models, not the encryption logic. +- **Database operations in `services/`** (never in core). The `prekey_service.py`, `message_service.py`, and `auth_service.py` files handle all SQLModel queries and SurrealDB operations. They receive an `AsyncSession` as a parameter (dependency injection) and call into `frontend/src/crypto/` for crypto operations. The crypto layer never sees a session object, and the service layer never calls OpenSSL directly. This means a database schema change only affects service files and models, not the encryption logic. -- **Pydantic schemas for ALL API boundaries.** Every WebSocket message type has a corresponding schema in `schemas/websocket.py` (e.g., `EncryptedMessageWS`, `TypingIndicatorWS`, `ErrorMessageWS`). Every HTTP request/response has a schema in `schemas/auth.py`, `schemas/rooms.py`, etc. This provides runtime validation: if a client sends a malformed payload (missing fields, wrong types, oversized strings), Pydantic rejects it before the data reaches service logic. For example, `ENCRYPTED_CONTENT_MAX_LENGTH = 50000` in `config.py:44` caps the ciphertext field length, preventing a client from sending a 10GB ciphertext that would consume server memory. +- **Pydantic schemas for ALL API boundaries.** Every WebSocket message type has a corresponding schema in `schemas/websocket.py` (e.g., `EncryptedMessageWS`, `TypingIndicatorWS`, `ErrorMessageWS`). Every HTTP request/response has a schema in `schemas/auth.py`, `schemas/rooms.py`, etc. This provides runtime validation: if a client sends a malformed payload (missing fields, wrong types, oversized strings), Pydantic rejects it before the data reaches service logic. For example, `ENCRYPTED_CONTENT_MAX_LENGTH = 50000` in `config.py` caps the ciphertext field length, preventing a client from sending a 10GB ciphertext that would consume server memory. -- **Module-level singletons for stateless managers.** At the bottom of `x3dh_manager.py:353`: `x3dh_manager = X3DHManager()`. At the bottom of `double_ratchet.py:419`: `double_ratchet = DoubleRatchet()`. At the bottom of `websocket_manager.py:296`: `connection_manager = ConnectionManager()`. These classes either hold no per-request state (X3DHManager, DoubleRatchet) or manage global connection state (ConnectionManager). A single instance per process is safe because: (a) stateless managers have no mutable state, so concurrent async tasks cannot interfere with each other; (b) the ConnectionManager's mutable state (`active_connections` dict) is accessed within a single event loop, so there are no threading concerns. +- **Module-level singletons for stateless managers.** At the bottom of `backend/app/core/websocket_manager.py`: `connection_manager = ConnectionManager`. At the bottom of `backend/app/core/redis_manager.py` and `backend/app/core/surreal_manager.py`: `redis_manager` and `surreal_db`. The X3DH and Double Ratchet implementations live in the browser as plain TypeScript modules with exported functions, so there's no equivalent server-side singleton for them. The Python singletons either manage shared connection pools or are stateless wrappers; they're safe to share across the process because (a) stateless managers have no mutable state, so concurrent async tasks cannot interfere with each other, and (b) `connection_manager.active_connections` is touched only inside the single event loop. -- **Async all the way through (no blocking I/O).** Every database query uses `await session.execute(...)`. Every WebSocket operation uses `await websocket.send_json(...)`. Every Redis call uses async methods. Every SurrealDB call uses async methods. The only synchronous operations are the CPU-bound cryptographic computations (HMAC, HKDF, AES-GCM, X25519 DH exchange), which complete in microseconds and do not benefit from async. If a crypto operation ever became slow (e.g., Argon2 password hashing taking 500ms), it would need to be offloaded to a thread pool via `asyncio.to_thread()` to avoid blocking the event loop. But the operations used here are all sub-millisecond. +- **Async all the way through (no blocking I/O).** Every database query uses `await session.execute(...)`. Every WebSocket operation uses `await websocket.send_json(...)`. Every Redis call uses async methods. Every SurrealDB call uses async methods. The only synchronous operations are the CPU-bound cryptographic computations (HMAC, HKDF, AES-GCM, X25519 DH exchange), which complete in microseconds and do not benefit from async. If a crypto operation ever became slow (e.g., Argon2 password hashing taking 500ms), it would need to be offloaded to a thread pool via `asyncio.to_thread` to avoid blocking the event loop. But the operations used here are all sub-millisecond. - **Custom exception hierarchy in `exceptions.py`.** Every error type has its own exception class (`EncryptionError`, `DecryptionError`, `KeyExchangeError`, `DatabaseError`, `UserNotFoundError`, etc.), all inheriting from `AppException`. This allows `exception_handlers.py` to map each exception type to an appropriate HTTP status code and error response. A `UserNotFoundError` returns 404. An `EncryptionError` returns 500. A `ChallengeExpiredError` returns 401. Without the hierarchy, every error would be a generic 500, and the client would have no idea what went wrong. -- **Frontend mirrors the backend structure.** The frontend's `crypto/` directory parallels the backend's `core/encryption/`. `primitives.ts` provides the same low-level operations as Python's `cryptography` library. `x3dh.ts` parallels `x3dh_manager.py`. `double-ratchet.ts` parallels `double_ratchet.py`. `crypto-service.ts` parallels `message_service.py` (the orchestration layer). This symmetry makes it easier to trace a bug: if decryption fails, you compare the frontend and backend implementations step by step, checking that the same constants, the same byte orderings, and the same HKDF parameters are used on both sides. +- **Frontend mirrors the backend structure.** The frontend's `crypto/` directory parallels the backend's `frontend/src/crypto/`. `primitives.ts` provides the same low-level operations as Python's `cryptography` library. `x3dh.ts` parallels `frontend/src/crypto/x3dh.ts`. `double-ratchet.ts` parallels `frontend/src/crypto/double-ratchet.ts`. `crypto-service.ts` parallels `message_service.py` (the orchestration layer). This symmetry makes it easier to trace a bug: if decryption fails, you compare the frontend and backend implementations step by step, checking that the same constants, the same byte orderings, and the same HKDF parameters are used on both sides. --- @@ -1103,7 +1101,7 @@ The codebase follows a strict layering that separates concerns and minimizes the - **py_webauthn:** Implements the WebAuthn/FIDO2 server-side protocol. Handles the complex ASN.1 parsing of attestation objects, CBOR decoding of authenticator data, signature verification across multiple algorithm families (ES256, RS256, EdDSA), and challenge lifecycle management. The WebAuthn specification is over 200 pages. Writing a compliant implementation from scratch would take months and invite subtle bugs in the CBOR parsing alone. py_webauthn also provides convenient helpers like `bytes_to_base64url` and `base64url_to_bytes` that we use throughout the encryption code. -- **sqlmodel:** Combines SQLAlchemy's query builder with Pydantic's validation. You define a model once (like `RatchetState` or `IdentityKey`) and get both a database table schema and a Pydantic serialization schema. The `AsyncSession` support via `sqlmodel.ext.asyncio.session` integrates cleanly with FastAPI's async request handlers. The alternative would be using raw SQLAlchemy models plus separate Pydantic schemas, which doubles the model definitions and introduces synchronization risk (changing a column in the SQLAlchemy model but forgetting to update the Pydantic schema). +- **sqlmodel:** Combines SQLAlchemy's query builder with Pydantic's validation. You define a model once (like `User` or `IdentityKey`) and get both a database table schema and a Pydantic serialization schema. The `AsyncSession` support via `sqlmodel.ext.asyncio.session` integrates cleanly with FastAPI's async request handlers. The alternative would be using raw SQLAlchemy models plus separate Pydantic schemas, which doubles the model definitions and introduces synchronization risk (changing a column in the SQLAlchemy model but forgetting to update the Pydantic schema). - **surrealdb:** The Python client for SurrealDB's WebSocket protocol. The key feature is live query support: `LIVE SELECT * FROM messages WHERE recipient_id = $user_id` creates a persistent subscription that pushes new matching records to a callback. This eliminates the need for a separate pub/sub system (like Redis Pub/Sub or RabbitMQ) for real-time message delivery. SurrealDB serves as both the message store and the real-time event bus. diff --git a/PROJECTS/advanced/encrypted-p2p-chat/learn/04-CHALLENGES.md b/PROJECTS/advanced/encrypted-p2p-chat/learn/04-CHALLENGES.md index bfb42821..d1e315ef 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/learn/04-CHALLENGES.md +++ b/PROJECTS/advanced/encrypted-p2p-chat/learn/04-CHALLENGES.md @@ -25,23 +25,23 @@ Each challenge references actual files in this project. The line numbers point y The backend already has the scaffolding for this. Look at these files: -- `backend/app/config.py:54` defines `WS_MESSAGE_TYPE_RECEIPT = "receipt"` already -- `backend/app/services/websocket_service.py:69-70` already routes receipt messages to `handle_read_receipt` -- `backend/app/services/websocket_service.py:272-314` has a working `handle_read_receipt` implementation -- `backend/app/schemas/websocket.py:62-69` defines `ReadReceiptWS` with `message_id`, `user_id`, and `read_at` +- `backend/app/config.py` defines `WS_MESSAGE_TYPE_RECEIPT = "receipt"` already +- `backend/app/services/backend/app/services/websocket_service.py` already routes receipt messages to `handle_read_receipt` +- `backend/app/services/backend/app/services/websocket_service.py` has a working `handle_read_receipt` implementation +- `backend/app/schemas/websocket.py` defines `ReadReceiptWS` with `message_id`, `user_id`, and `read_at` So the backend is done. The work is on the frontend. **Implementation approach:** 1. When Bob's client decrypts and displays a message from Alice, emit a receipt through the WebSocket: - ```json - { - "type": "receipt", - "message_id": "messages:abc123", - "sender_id": "alice-uuid-here" - } - ``` + ```json + { + "type": "receipt", + "message_id": "messages:abc123", + "sender_id": "alice-uuid-here" + } + ``` 2. Alice's client receives the `ReadReceiptWS` via the WebSocket message handler at `frontend/src/websocket/message-handlers.ts`. Add a handler for the `"receipt"` message type. @@ -79,11 +79,11 @@ So the backend is done. The work is on the frontend. This feature is substantially built already. Study what exists: -- `backend/app/config.py:52` defines `WS_MESSAGE_TYPE_TYPING = "typing"` -- `backend/app/services/websocket_service.py:65-66` routes typing messages to `handle_typing_indicator` -- `backend/app/services/websocket_service.py:181-224` broadcasts typing events to the room via `connection_manager.broadcast_to_room` -- `backend/app/schemas/websocket.py:42-49` defines `TypingIndicatorWS` with `user_id`, `room_id`, `is_typing` -- `frontend/src/stores/typing.store.ts:1-113` is a complete typing store with auto-clear timeouts (5 seconds, line 10) +- `backend/app/config.py` defines `WS_MESSAGE_TYPE_TYPING = "typing"` +- `backend/app/services/backend/app/services/websocket_service.py` routes typing messages to `handle_typing_indicator` +- `backend/app/services/backend/app/services/websocket_service.py` broadcasts typing events to the room via `connection_manager.broadcast_to_room` +- `backend/app/schemas/websocket.py` defines `TypingIndicatorWS` with `user_id`, `room_id`, `is_typing` +- `frontend/src/stores/typing.store.ts` is a complete typing store with auto-clear timeouts (5 seconds,) - `frontend/src/components/Chat/TypingIndicator.tsx` already renders the typing indicator UI The backend and most of the frontend state management are done. What is missing: @@ -100,11 +100,11 @@ The backend and most of the frontend state management are done. What is missing: **Throttling pattern:** ``` -Keystroke at t=0ms -> send "typing: true" +Keystroke at t=0ms -> send "typing: true" Keystroke at t=500ms -> suppress (within 3s window) -Keystroke at t=1.2s -> suppress -Keystroke at t=4.5s -> send "typing: true" (new window) -No input for 3s -> send "typing: false" +Keystroke at t=1.2s -> suppress +Keystroke at t=4.5s -> send "typing: true" (new window) +No input for 3s -> send "typing: false" ``` **Do not encrypt typing events.** They are ephemeral metadata. Running them through the Double Ratchet would advance the ratchet state for throwaway data, wasting chain keys and increasing the risk of desynchronization. @@ -113,7 +113,7 @@ No input for 3s -> send "typing: false" - Open two tabs as different users in the same room - Start typing in Tab A - Verify "typing..." appears in Tab B within 1 second -- Stop typing and verify the indicator disappears after 5 seconds (the `TYPING_TIMEOUT_MS` constant at `typing.store.ts:10`) +- Stop typing and verify the indicator disappears after 5 seconds (the `TYPING_TIMEOUT_MS` constant at `typing.store.ts`) - Type rapidly and verify only one WebSocket event per 3-second window (check WS frames in DevTools) - Switch rooms and verify typing state resets @@ -123,7 +123,7 @@ No input for 3s -> send "typing: false" **What to build:** Display human-readable timestamps on messages ("2:34 PM", "Yesterday", "Jan 15") and ensure messages display in correct chronological order even when they arrive out of order over the WebSocket. -**Why it matters:** The Double Ratchet protocol handles cryptographic out-of-order delivery (see `double_ratchet.py:215-244` for the skipped message key logic), but the UI also needs to handle display ordering correctly. This is a common source of bugs in messaging apps. If Alice sends message A then message B, but B arrives at Bob first over the WebSocket, the UI must still display A before B. +**Why it matters:** The Double Ratchet protocol handles cryptographic out-of-order delivery (see `frontend/src/crypto/double-ratchet.ts` for the skipped message key logic), but the UI also needs to handle display ordering correctly. This is a common source of bugs in messaging apps. If Alice sends message A then message B, but B arrives at Bob first over the WebSocket, the UI must still display A before B. **What you will learn:** - Client-side message sorting with stable ordering @@ -132,22 +132,22 @@ No input for 3s -> send "typing: false" **Where to start reading:** -- `backend/app/schemas/websocket.py:26-39` shows `EncryptedMessageWS` includes a `timestamp` field -- `frontend/src/crypto/message-store.ts:95-119` already sorts messages by `created_at` (line 106-107) +- `backend/app/schemas/websocket.py` shows `EncryptedMessageWS` includes a `timestamp` field +- `frontend/src/crypto/message-store.ts` already sorts messages by `created_at` - `frontend/src/lib/date.ts` is the existing date utility file - `frontend/src/components/Chat/MessageBubble.tsx` renders individual messages **Implementation approach:** -1. The message store at `message-store.ts:106-107` already sorts by `created_at`. Verify this works when messages arrive out of order by checking that new messages insert at the correct position, not just appended at the end. +1. The message store at `message-store.ts` already sorts by `created_at`. Verify this works when messages arrive out of order by checking that new messages insert at the correct position, not just appended at the end. 2. Create a timestamp formatter. Use `Intl.RelativeTimeFormat` for recent messages and `Intl.DateTimeFormat` for older ones: - - Under 1 minute: "Just now" - - Under 1 hour: "12 min ago" - - Same day: "2:34 PM" - - Yesterday: "Yesterday at 2:34 PM" - - Same year: "Jan 15 at 2:34 PM" - - Older: "Jan 15, 2025 at 2:34 PM" + - Under 1 minute: "Just now" + - Under 1 hour: "12 min ago" + - Same day: "2:34 PM" + - Yesterday: "Yesterday at 2:34 PM" + - Same year: "Jan 15 at 2:34 PM" + - Older: "Jan 15, 2025 at 2:34 PM" 3. Add date separator headers between messages from different days. When rendering the message list, compare each message's date with the previous message. If they differ, insert a "--- January 15, 2025 ---" separator. @@ -175,20 +175,20 @@ No input for 3s -> send "typing: false" The backend infrastructure is already built: -- `backend/app/core/websocket_manager.py:74` calls `presence_service.set_user_online(user_id)` on WebSocket connect -- `backend/app/core/websocket_manager.py:108` calls `presence_service.set_user_offline(user_id)` on disconnect (only when the last connection for that user closes) -- `backend/app/services/presence_service.py:22-35` sets online status in SurrealDB -- `backend/app/services/presence_service.py:37-50` sets offline status -- `backend/app/services/websocket_service.py:226-270` handles presence update messages +- `backend/app/core/websocket_manager.py` calls `presence_service.set_user_online(user_id)` on WebSocket connect +- `backend/app/core/websocket_manager.py` calls `presence_service.set_user_offline(user_id)` on disconnect (only when the last connection for that user closes) +- `backend/app/services/presence_service.py` sets online status in SurrealDB +- `backend/app/services/presence_service.py` sets offline status +- `backend/app/services/backend/app/services/websocket_service.py` handles presence update messages The frontend store is also ready: -- `frontend/src/stores/presence.store.ts:1-72` has `$presenceByUser` map, `setUserPresence`, `getUserStatus`, `isUserOnline` +- `frontend/src/stores/presence.store.ts` has `$presenceByUser` map, `setUserPresence`, `getUserStatus`, `isUserOnline` - `frontend/src/components/Chat/OnlineStatus.tsx` already renders the status indicator **Implementation approach:** -1. When a user connects via WebSocket, the backend already updates SurrealDB presence. What is missing is broadcasting that change to the user's contacts. In `websocket_manager.py`, after `presence_service.set_user_online(user_id)` succeeds (line 74), broadcast a presence update to all rooms that user belongs to. +1. When a user connects via WebSocket, the backend already updates SurrealDB presence. What is missing is broadcasting that change to the user's contacts. In `websocket_manager.py`, after `presence_service.set_user_online(user_id)` succeeds , broadcast a presence update to all rooms that user belongs to. 2. Add a `PresenceUpdateWS` handler in the frontend WebSocket message handler. When a `"presence"` message arrives, call `setUserPresence` from the presence store. @@ -223,30 +223,30 @@ Signal allows searching decrypted messages on-device. WhatsApp backs up messages **Where to start reading:** -- `frontend/src/crypto/message-store.ts:1-211` is the existing IndexedDB message store with `decrypted_messages` object store -- `frontend/src/crypto/message-store.ts:34-39` creates indexes on `room_id`, `created_at`, and a compound `room_created` index -- `frontend/src/crypto/message-store.ts:95-119` shows how messages are queried by room -- `frontend/src/crypto/key-store.ts:392-412` shows `clearAllKeys()` which wipes all crypto state on logout +- `frontend/src/crypto/message-store.ts` is the existing IndexedDB message store with `decrypted_messages` object store +- `frontend/src/crypto/message-store.ts` creates indexes on `room_id`, `created_at`, and a compound `room_created` index +- `frontend/src/crypto/message-store.ts` shows how messages are queried by room +- `frontend/src/crypto/key-store.ts` shows `clearAllKeys` which wipes all crypto state on logout **Implementation approach:** -1. Decrypted messages are already stored in IndexedDB at `message-store.ts:61-67` (`saveDecryptedMessage`). The plaintext is already persisted locally. You do not need to add a separate plaintext store. +1. Decrypted messages are already stored in IndexedDB at `message-store.ts` (`saveDecryptedMessage`). The plaintext is already persisted locally. You do not need to add a separate plaintext store. 2. Add a `searchMessages` function to `message-store.ts`. IndexedDB does not support full-text search natively. You have two options: - Option A (simple): Load all messages for the user, filter with `String.prototype.includes()` or a regex. This works for small message histories (under 10,000 messages). + Option A (simple): Load all messages for the user, filter with `String.prototype.includes` or a regex. This works for small message histories (under 10,000 messages). - Option B (performant): Build a simple inverted index in a separate IndexedDB object store. On each message save, tokenize the plaintext, store `{token: string, message_id: string}` entries. Search queries look up tokens in the index. This scales to hundreds of thousands of messages. + Option B (performant): Build a simple inverted index in a separate IndexedDB object store. On each message save, tokenize the plaintext, store `{token: string, message_id: string}` entries. Search queries look up tokens in the index. This scales to hundreds of thousands of messages. 3. Build a search UI. Add a search input to the sidebar. Results should show message previews with the matching text highlighted. Clicking a result should navigate to that message in the conversation and scroll it into view. -4. Clear the search index on logout. Add this to whatever logout function already calls `clearAllKeys()` and `clearAllMessages()`. +4. Clear the search index on logout. Add this to whatever logout function already calls `clearAllKeys` and `clearAllMessages`. **Security considerations:** The local plaintext store is a security tradeoff. If the device is compromised, the search history exposes message content. Signal handles this by only searching messages from the current app session (not backed by a persistent index). Consider offering a setting: "Enable message search (stores decrypted messages locally)" vs. "Maximum security (no local message storage)". -Also consider: IndexedDB data survives browser cache clears in some browsers. Use `indexedDB.deleteDatabase()` on logout, not just clearing object stores. +Also consider: IndexedDB data survives browser cache clears in some browsers. Use `indexedDB.deleteDatabase` on logout, not just clearing object stores. **How to test:** - Send several messages containing known keywords between two users @@ -272,48 +272,48 @@ Also consider: IndexedDB data survives browser cache clears in some browsers. Us **Where to start reading:** -- `frontend/src/crypto/primitives.ts:224-259` is the `aesGcmEncrypt` function. It accepts `Uint8Array` for plaintext, which means binary data works directly with no conversion needed. -- `frontend/src/crypto/primitives.ts:261-292` is `aesGcmDecrypt` -- `backend/app/services/message_service.py:269-314` is `store_encrypted_message` which stores to SurrealDB -- `backend/app/config.py:44` defines `ENCRYPTED_CONTENT_MAX_LENGTH = 50000` (this limits inline message content, not file uploads) +- `frontend/src/crypto/frontend/src/crypto/primitives.ts` is the `aesGcmEncrypt` function. It accepts `Uint8Array` for plaintext, which means binary data works directly with no conversion needed. +- `frontend/src/crypto/frontend/src/crypto/primitives.ts` is `aesGcmDecrypt` +- `backend/app/services/backend/app/services/message_service.py` is `store_encrypted_message` which stores to SurrealDB +- `backend/app/config.py` defines `ENCRYPTED_CONTENT_MAX_LENGTH = 50000` (this limits inline message content, not file uploads) **Implementation approach:** 1. **Client: Read and encrypt the file** - - Use `FileReader.readAsArrayBuffer()` to get the raw bytes - - For small files (under 1MB): encrypt the entire file as one AES-256-GCM operation using a message key from the Double Ratchet - - For large files (over 1MB): chunk into 1MB segments. Encrypt each chunk with a derived key: `chunk_key = HKDF(message_key, salt=chunk_index)` - - Each chunk gets its own nonce (never reuse nonces) + - Use `FileReader.readAsArrayBuffer` to get the raw bytes + - For small files (under 1MB): encrypt the entire file as one AES-256-GCM operation using a message key from the Double Ratchet + - For large files (over 1MB): chunk into 1MB segments. Encrypt each chunk with a derived key: `chunk_key = HKDF(message_key, salt=chunk_index)` + - Each chunk gets its own nonce (never reuse nonces) 2. **Client: Upload encrypted blob** - - Create a new backend endpoint: `POST /api/messages/file` - - Send the encrypted blob as multipart form data - - Include metadata in the request: original filename, MIME type, file size, number of chunks, encryption header (same format as text message headers) + - Create a new backend endpoint: `POST /api/messages/file` + - Send the encrypted blob as multipart form data + - Include metadata in the request: original filename, MIME type, file size, number of chunks, encryption header (same format as text message headers) 3. **Backend: Store encrypted file** - - Store the encrypted blob on disk or in object storage (not in SurrealDB, which is not designed for large binary data) - - Store file metadata in SurrealDB: `file_id`, `sender_id`, `recipient_id`, `room_id`, `filename`, `mime_type`, `size`, `chunk_count`, `storage_path` - - Return a `file_id` to the client + - Store the encrypted blob on disk or in object storage (not in SurrealDB, which is not designed for large binary data) + - Store file metadata in SurrealDB: `file_id`, `sender_id`, `recipient_id`, `room_id`, `filename`, `mime_type`, `size`, `chunk_count`, `storage_path` + - Return a `file_id` to the client 4. **Client: Send file message** - - Send a regular encrypted message through the WebSocket, but the plaintext content is JSON metadata: - ```json - { - "type": "file", - "file_id": "abc123", - "filename": "document.pdf", - "mime_type": "application/pdf", - "size": 1048576, - "thumbnail": "" - } - ``` - - The recipient decrypts this metadata, then downloads the encrypted file by `file_id`, then decrypts the file client-side + - Send a regular encrypted message through the WebSocket, but the plaintext content is JSON metadata: + ```json + { + "type": "file", + "file_id": "abc123", + "filename": "document.pdf", + "mime_type": "application/pdf", + "size": 1048576, + "thumbnail": "" + } + ``` + - The recipient decrypts this metadata, then downloads the encrypted file by `file_id`, then decrypts the file client-side 5. **For images: generate encrypted thumbnails** - - Before uploading, use `` to resize the image to a thumbnail (200x200) - - Encrypt the thumbnail separately - - Include the encrypted thumbnail inline in the message (it is small enough) - - Render the thumbnail immediately, load the full image on click + - Before uploading, use `` to resize the image to a thumbnail (200x200) + - Encrypt the thumbnail separately + - Include the encrypted thumbnail inline in the message (it is small enough) + - Render the thumbnail immediately, load the full image on click **Security considerations:** - Validate file size limits on the backend to prevent DoS (suggest 100MB max) @@ -371,12 +371,12 @@ Each approach has different properties for security, UX, and complexity. This ch **Where to start reading:** -- `backend/app/core/websocket_manager.py:39` stores `active_connections: dict[UUID, list[WebSocket]]` which already supports multiple connections per user -- `backend/app/core/websocket_manager.py:52` enforces `WS_MAX_CONNECTIONS_PER_USER` (default 5, see `config.py:141`) -- `backend/app/core/websocket_manager.py:145-153` iterates over ALL connections for a user when sending a message -- `backend/app/services/prekey_service.py:152-219` initializes user keys with identity key, signed prekey, and one-time prekeys +- `backend/app/core/websocket_manager.py` stores `active_connections: dict[UUID, list[WebSocket]]` which already supports multiple connections per user +- `backend/app/core/websocket_manager.py` enforces `WS_MAX_CONNECTIONS_PER_USER` (default 5, see `config.py`) +- `backend/app/core/websocket_manager.py` iterates over ALL connections for a user when sending a message +- `backend/app/services/backend/app/services/prekey_service.py` initializes user keys with identity key, signed prekey, and one-time prekeys - `backend/app/models/IdentityKey.py` stores one identity key per user (this needs to change) -- `backend/app/services/message_service.py:48-166` `initialize_conversation` creates one ratchet per user pair (this also needs to change) +- `backend/app/services/backend/app/services/message_service.py` `initialize_conversation` creates one ratchet per user pair (this also needs to change) **Architecture change:** @@ -387,8 +387,8 @@ Alice ----[one ratchet session]----> Bob With multi-device, it becomes: ``` -Alice-Phone ----[ratchet A1-B1]----> Bob-Phone -Alice-Phone ----[ratchet A1-B2]----> Bob-Laptop +Alice-Phone ----[ratchet A1-B1]----> Bob-Phone +Alice-Phone ----[ratchet A1-B2]----> Bob-Laptop Alice-Laptop ----[ratchet A2-B1]----> Bob-Phone Alice-Laptop ----[ratchet A2-B2]----> Bob-Laptop ``` @@ -398,20 +398,19 @@ If Alice has 2 devices and Bob has 3 devices, there are 2 x 3 = 6 ratchet sessio **Implementation phases:** **Phase 1: Database schema changes** -- Add a `device_id` column to the `IdentityKey` model. Change the unique constraint from `(user_id)` to `(user_id, device_id)`. -- Add a `device_id` column to `RatchetState`. Change the unique constraint to `(user_id, peer_user_id, peer_device_id)`. +- Add a `device_id` column to the `IdentityKey`, `SignedPrekey`, and `OneTimePrekey` models. Change the unique constraint on `IdentityKey` from `(user_id)` to `(user_id, device_id)`. - Add a `devices` table: `device_id`, `user_id`, `device_name`, `created_at`, `last_active`. +- Client-side ratchet state in `frontend/src/crypto/key-store.ts` becomes keyed on `(peer_user_id, peer_device_id)` instead of just `peer_id`. **Phase 2: Device registration** - When a user registers a new device (via WebAuthn), generate a new identity key pair for that device. - Each device uploads its own prekey bundle (identity key, signed prekey, one-time prekeys). -- The prekey bundle endpoint (`prekey_service.py:293-361`) needs a `device_id` parameter. +- The prekey bundle endpoint (`backend/app/services/prekey_service.py`) needs a `device_id` parameter. **Phase 3: Message fan-out** -- When Alice sends to Bob, the backend looks up all of Bob's devices. -- For each device, it checks if a ratchet session exists. If not, it performs X3DH with that device's prekey bundle. -- Alice's client encrypts the message once per recipient device. -- The WebSocket forward logic at `websocket_service.py:87-179` sends the appropriate ciphertext to each device. +- Alice's client fetches all of Bob's per-device prekey bundles, then runs X3DH and encrypts once per device. +- The WebSocket payload from Alice to the server now carries `device_id` alongside `recipient_id`. +- The forwarding logic in `backend/app/services/websocket_service.py:handle_encrypted_message` routes each ciphertext to the recipient device's connection. **Phase 4: Device linking** - A new device should NOT just register independently. It should be "linked" by an existing device. @@ -422,13 +421,13 @@ If Alice has 2 devices and Bob has 3 devices, there are 2 x 3 = 6 ratchet sessio When Alice sends a message to Bob (who has 3 devices), the flow is: -1. Alice's client fetches prekey bundles for ALL of Bob's devices: `GET /api/encryption/prekeys/{bob_id}?all_devices=true` +1. Alice's client fetches prekey bundles for ALL of Bob's devices: `GET /encryption/prekey-bundle/{bob_id}?all_devices=true` 2. Alice performs X3DH separately with each device's prekey bundle (3 separate key exchanges) 3. Alice encrypts the message 3 times (once per ratchet session) 4. Alice sends 3 encrypted payloads to the server, each tagged with `(recipient_id, device_id)` 5. The server forwards each payload to the correct device -This means the `store_encrypted_message` function at `message_service.py:269-314` needs a `device_id` field. The SurrealDB message schema changes from `{sender_id, recipient_id, ciphertext}` to `{sender_id, sender_device_id, recipient_id, recipient_device_id, ciphertext}`. +This means the `store_encrypted_message` function at `backend/app/services/message_service.py` needs a `device_id` field. The SurrealDB message schema changes from `{sender_id, recipient_id, ciphertext}` to `{sender_id, sender_device_id, recipient_id, recipient_device_id, ciphertext}`. **Device verification:** @@ -437,9 +436,9 @@ How does Alice know she is encrypting to Bob's real devices and not a rogue devi Consider implementing a device list that shows all registered devices with their safety numbers: ``` Bob's Devices: - Phone (registered Jan 15) - Safety: 4a7b 2c3d ... - Laptop (registered Jan 20) - Safety: 9f1e 8d2c ... - Tablet (registered Feb 1) - Safety: 3b5a 7c4e ... + Phone (registered Jan 15) - Safety: 4a7b 2c3d ... + Laptop (registered Jan 20) - Safety: 9f1e 8d2c ... + Tablet (registered Feb 1) - Safety: 3b5a 7c4e ... ``` **How to test:** @@ -479,10 +478,10 @@ Signal uses Sender Keys for group chats. WhatsApp adopted the same approach. The **Where to start reading:** -- `backend/app/api/rooms.py:38-132` creates rooms with participants. This is where group support starts. -- `backend/app/core/encryption/double_ratchet.py:96-109` has the chain key derivation (`_kdf_ck`) which is the same pattern used in Sender Keys (single-direction chain) -- `backend/app/core/websocket_manager.py:155-175` `broadcast_to_room` sends to all room members -- `frontend/src/crypto/primitives.ts:224-259` AES-GCM encryption works for sender key messages too +- `backend/app/api/rooms.py` creates rooms with participants. This is where group support starts. +- `frontend/src/crypto/double-ratchet.ts` has the chain key derivation (`_kdf_ck`) which is the same pattern used in Sender Keys (single-direction chain) +- `backend/app/core/websocket_manager.py` `broadcast_to_room` sends to all room members +- `frontend/src/crypto/frontend/src/crypto/primitives.ts` AES-GCM encryption works for sender key messages too **Implementation phases:** @@ -498,52 +497,52 @@ Create these tables in SurrealDB: ``` sender_keys { - group_id: string, - user_id: string, - chain_key: bytes, - chain_iteration: int, - signing_public_key: bytes, - created_at: datetime + group_id: string, + user_id: string, + chain_key: bytes, + chain_iteration: int, + signing_public_key: bytes, + created_at: datetime } group_members { - group_id: string, - user_id: string, - role: string, // "admin" | "member" - joined_at: datetime + group_id: string, + user_id: string, + role: string, // "admin" | "member" + joined_at: datetime } ``` **Phase 3: Implementation** 1. **Group creation:** When Alice creates a group with Bob and Carol: - - Alice generates a sender key pair: `(chain_key_alice, signing_key_alice)` - - Alice sends her sender key to Bob via their existing 1:1 encrypted channel (Double Ratchet) - - Alice sends her sender key to Carol the same way - - Bob and Carol do the same (each distributes their sender key to all other members) - - Result: each member has N-1 sender keys (one per other member) + - Alice generates a sender key pair: `(chain_key_alice, signing_key_alice)` + - Alice sends her sender key to Bob via their existing 1:1 encrypted channel (Double Ratchet) + - Alice sends her sender key to Carol the same way + - Bob and Carol do the same (each distributes their sender key to all other members) + - Result: each member has N-1 sender keys (one per other member) 2. **Sending a message:** Alice encrypts the group message: - - Derive message key: `message_key = HMAC-SHA256(chain_key, 0x01)` - - Advance chain: `chain_key = HMAC-SHA256(chain_key, 0x02)` - - Encrypt: `ciphertext = AES-GCM(message_key, plaintext)` - - Sign: `signature = Ed25519.sign(signing_key, ciphertext)` - - Broadcast ciphertext + signature + chain_iteration to the group + - Derive message key: `message_key = HMAC-SHA256(chain_key, 0x01)` + - Advance chain: `chain_key = HMAC-SHA256(chain_key, 0x02)` + - Encrypt: `ciphertext = AES-GCM(message_key, plaintext)` + - Sign: `signature = Ed25519.sign(signing_key, ciphertext)` + - Broadcast ciphertext + signature + chain_iteration to the group 3. **Receiving a message:** Bob decrypts: - - Look up Alice's sender key by `(group_id, sender_id)` - - If `chain_iteration > local_iteration`: advance chain key to catch up - - Derive the message key, decrypt, verify signature + - Look up Alice's sender key by `(group_id, sender_id)` + - If `chain_iteration > local_iteration`: advance chain key to catch up + - Derive the message key, decrypt, verify signature 4. **Member removal:** When Carol is removed: - - Alice generates a NEW sender key pair - - Alice distributes the new sender key to Bob (but NOT Carol) - - Bob does the same - - Carol still has the old sender keys but they are no longer used + - Alice generates a NEW sender key pair + - Alice distributes the new sender key to Bob (but NOT Carol) + - Bob does the same + - Carol still has the old sender keys but they are no longer used 5. **Member addition:** When Dave joins: - - All existing members distribute their current sender keys to Dave via 1:1 channels - - Dave generates and distributes his sender key to all members + - All existing members distribute their current sender keys to Dave via 1:1 channels + - Dave generates and distributes his sender key to all members **Key rotation strategy:** @@ -592,22 +591,22 @@ The threat model that motivates this is "harvest now, decrypt later." An adversa **Where to start reading:** -- `backend/app/core/encryption/x3dh_manager.py:208-281` is `perform_x3dh_sender`. This is the exact function you will modify. -- `backend/app/core/encryption/x3dh_manager.py:241-255` is where the DH shared secrets are combined: - ```python - dh1 = alice_ik_private.exchange(bob_spk_public) # line 241 - dh2 = alice_ek_private.exchange(bob_ik_public) # line 242 - dh3 = alice_ek_private.exchange(bob_spk_public) # line 243 - # dh4 = alice_ek_private.exchange(bob_opk_public) # line 251 (optional) - key_material = dh1 + dh2 + dh3 [+ dh4] - ``` -- `backend/app/core/encryption/x3dh_manager.py:257-264` is where HKDF derives the shared key: - ```python - 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) - ``` +- `frontend/src/crypto/x3dh.ts` is `perform_x3dh_sender`. This is the exact function you will modify. +- `frontend/src/crypto/x3dh.ts` is where the DH shared secrets are combined: + ```python + dh1 = alice_ik_private.exchange(bob_spk_public) + dh2 = alice_ek_private.exchange(bob_ik_public) + dh3 = alice_ek_private.exchange(bob_spk_public) + # dh4 = alice_ek_private.exchange(bob_opk_public) (optional) + key_material = dh1 + dh2 + dh3 [+ dh4] + ``` +- `frontend/src/crypto/x3dh.ts` is where HKDF derives the shared key: + ```python + 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) + ``` - `backend/pyproject.toml:30` already includes `liboqs-python>=0.14.1` in dependencies **The hybrid approach:** @@ -618,9 +617,9 @@ The key insight is that you combine BOTH classical and post-quantum shared secre - Both must be broken simultaneously to compromise the session ``` -Classical: SK_classical = HKDF(DH1 || DH2 || DH3 [|| DH4]) +Classical: SK_classical = HKDF(DH1 || DH2 || DH3 [|| DH4]) Post-quantum: SK_pq = Kyber.Decapsulate(ciphertext, secret_key) -Combined: SK = HKDF(SK_classical || SK_pq, info=b'PQXDH') +Combined: SK = HKDF(SK_classical || SK_pq, info=b'PQXDH') ``` **Implementation phases:** @@ -640,27 +639,27 @@ Understand the difference between a KEM (Key Encapsulation Mechanism) and a DH e **Phase 2: Prekey bundle extension** -1. Add a `kyber_public_key` field to the `PreKeyBundle` dataclass at `x3dh_manager.py:34-42` -2. When generating prekeys (`prekey_service.py:152-219`), also generate a Kyber keypair using `liboqs-python`: - ```python - import oqs - kem = oqs.KeyEncapsulation("Kyber768") - kyber_public = kem.generate_keypair() - kyber_private = kem.export_secret_key() - ``` +1. Add a `kyber_public_key` field to the `PreKeyBundle` dataclass at `frontend/src/crypto/x3dh.ts` +2. When generating prekeys (`backend/app/services/prekey_service.py`), also generate a Kyber keypair using `liboqs-python`: + ```python + import oqs + kem = oqs.KeyEncapsulation("Kyber768") + kyber_public = kem.generate_keypair + kyber_private = kem.export_secret_key + ``` 3. Store the Kyber public key in the prekey bundle, private key in the database **Phase 3: X3DH extension** -Modify `perform_x3dh_sender` at `x3dh_manager.py:208-281`: +Modify `perform_x3dh_sender` at `frontend/src/crypto/x3dh.ts`: -1. After computing the classical `key_material` (line 252-255), check if the bundle includes a Kyber key +1. After computing the classical `key_material` , check if the bundle includes a Kyber key 2. If yes: encapsulate against the Kyber public key to get `(pq_shared_secret, pq_ciphertext)` 3. Concatenate: `combined_material = key_material + pq_shared_secret` 4. Change the HKDF info parameter: `info=b'PQXDH'` (to distinguish from classical sessions) 5. Include `pq_ciphertext` in the `X3DHResult` so it can be sent to the recipient -Modify `perform_x3dh_receiver` at `x3dh_manager.py:283-350`: +Modify `perform_x3dh_receiver` at `frontend/src/crypto/x3dh.ts`: 1. After computing the classical key material, check if a PQ ciphertext was provided 2. If yes: decapsulate to recover `pq_shared_secret` @@ -668,7 +667,7 @@ Modify `perform_x3dh_receiver` at `x3dh_manager.py:283-350`: **Phase 4: Backwards compatibility** -If the peer does not have a Kyber public key in their prekey bundle (they have not upgraded), fall back to classical-only X3DH. The code path at `x3dh_manager.py:246-255` already handles the optional one-time prekey with an if/else. Use the same pattern for Kyber. +If the peer does not have a Kyber public key in their prekey bundle (they have not upgraded), fall back to classical-only X3DH. The code path at `frontend/src/crypto/x3dh.ts` already handles the optional one-time prekey with an if/else. Use the same pattern for Kyber. **Key sizes to be aware of:** - X25519 public key: 32 bytes @@ -702,43 +701,43 @@ This challenge implements true cryptographic deletion: the ciphertext remains in **Where to start reading:** -- `backend/app/core/encryption/double_ratchet.py:96-109` is `_kdf_ck` which derives message keys. Each message gets a unique key. -- `backend/app/core/encryption/double_ratchet.py:215-244` is `_store_skipped_message_keys` with eviction logic at line 232-234. This is the pattern you will extend for TTL-based eviction. -- `backend/app/core/encryption/double_ratchet.py:246-258` is `_evict_oldest_skipped_keys` which deletes keys. -- `frontend/src/crypto/key-store.ts:353-358` stores ratchet state in IndexedDB -- `frontend/src/crypto/primitives.ts:294-298` has `generateRandomBytes` using `crypto.getRandomValues` +- `frontend/src/crypto/double-ratchet.ts` is `_kdf_ck` which derives message keys. Each message gets a unique key. +- `frontend/src/crypto/double-ratchet.ts` is `_store_skipped_message_keys` with eviction logic -234. This is the pattern you will extend for TTL-based eviction. +- `frontend/src/crypto/double-ratchet.ts` is `_evict_oldest_skipped_keys` which deletes keys. +- `frontend/src/crypto/key-store.ts` stores ratchet state in IndexedDB +- `frontend/src/crypto/frontend/src/crypto/primitives.ts` has `generateRandomBytes` using `crypto.getRandomValues` **Implementation approach:** 1. **Conversation setting:** Add a per-conversation setting: `disappear_after_seconds`. Values: 0 (disabled), 30, 300, 3600, 86400, 604800. Store this in the room metadata in SurrealDB. 2. **Message key storage with TTL:** When a message is encrypted, the message key is normally discarded after encryption. For disappearing messages, the RECIPIENT stores the message key in IndexedDB with a TTL: - ``` - { - message_id: "msg_abc123", - message_key: <32 bytes>, - created_at: "2025-01-15T14:30:00Z", - expires_at: "2025-01-15T15:30:00Z" // created_at + disappear_after_seconds - } - ``` + ``` + { + message_id: "msg_abc123", + message_key: <32 bytes>, + created_at: "2025-01-15T14:30:00Z", + expires_at: "2025-01-15T15:30:00Z" // created_at + disappear_after_seconds + } + ``` 3. **Timer starts on read, not on send.** The recipient's timer starts when they first decrypt the message. If the recipient is offline for a week, they still get the full timer duration after reading. 4. **Key destruction:** When the timer expires: - - Frontend: overwrite the key material with random bytes before deleting from IndexedDB. In JavaScript: `crypto.getRandomValues(keyBuffer)` then delete. - - Backend: if you store message keys server-side (for the deprecated server-side encryption path), overwrite with `os.urandom(32)` then delete. + - Frontend: overwrite the key material with random bytes before deleting from IndexedDB. In JavaScript: `crypto.getRandomValues(keyBuffer)` then delete. + - Backend: just delete the SurrealDB row. The server holds no keys, so there's nothing to wipe — but the recipient's locally-cached plaintext (and message key, if it's still in the skipped-key cache) needs to be overwritten on the client. 5. **UI changes:** - - Show a countdown timer on disappearing messages - - After expiration, replace the message content with "[Message expired]" - - The ciphertext remains in SurrealDB but is now permanently unreadable + - Show a countdown timer on disappearing messages + - After expiration, replace the message content with "[Message expired]" + - The ciphertext remains in SurrealDB but is now permanently unreadable **The hard parts:** - What happens if the recipient never reads the message? The sender's copy should still eventually disappear. Implement a "maximum lifetime" that triggers regardless of read status. - What about screenshots? You cannot prevent them. This is a social problem, not a technical one. Signal shows a notification when a screenshot is taken (on mobile), but this is best-effort. - JavaScript garbage collection does not guarantee memory is wiped. When you overwrite a `Uint8Array`, the old values may still exist in memory until the GC reclaims the page. True secure deletion in JavaScript is not possible. Document this limitation. -- The skipped message key cache (`double_ratchet.py:47-49`) stores keys for out-of-order messages. These must also respect the TTL. +- The skipped message key cache (`frontend/src/crypto/double-ratchet.ts`) stores keys for out-of-order messages. These must also respect the TTL. **How to test:** - Enable disappearing messages (30 second timer) in a conversation @@ -759,9 +758,9 @@ This challenge implements true cryptographic deletion: the ciphertext remains in **What to build:** Implement deniable authentication so that neither Alice nor Bob can prove to a third party that the other sent a specific message. This is a privacy property of the Signal Protocol that most implementations skip or get wrong. -**Prerequisites:** Solid understanding of the X3DH implementation at `x3dh_manager.py:56-353` +**Prerequisites:** Solid understanding of the X3DH implementation at `frontend/src/crypto/x3dh.ts` -**Why it matters:** In the current implementation, if Alice has Bob's signed messages (ciphertext + authentication tags), she could potentially show them to a third party to prove Bob said something. This is because the associated data (`x3dh_manager.py:272`) binds both identity keys to the session: `associated_data = alice_ik_public_bytes + bob_ik_public_bytes`. +**Why it matters:** In the current implementation, if Alice has Bob's signed messages (ciphertext + authentication tags), she could potentially show them to a third party to prove Bob said something. This is because the associated data (`frontend/src/crypto/x3dh.ts`) binds both identity keys to the session: `associated_data = alice_ik_public_bytes + bob_ik_public_bytes`. Deniable authentication means that Alice could have forged the messages herself (because she also has the shared secret), so they are not valid cryptographic proof of Bob's authorship. This matters for journalists, activists, whistleblowers, and anyone who might be legally compelled to prove message authorship. @@ -773,9 +772,9 @@ In a deniable protocol, the authentication MAC can be computed by EITHER party, **Where to start reading:** -- `x3dh_manager.py:272` is the associated data computation: `associated_data = alice_ik_public_bytes + bob_ik_public_bytes` -- `double_ratchet.py:323-362` is `encrypt_message` where associated data is used in AES-GCM -- `double_ratchet.py:364-416` is `decrypt_message` which verifies the authentication tag +- `frontend/src/crypto/x3dh.ts` is the associated data computation: `associated_data = alice_ik_public_bytes + bob_ik_public_bytes` +- `frontend/src/crypto/double-ratchet.ts` is `encrypt_message` where associated data is used in AES-GCM +- `frontend/src/crypto/double-ratchet.ts` is `decrypt_message` which verifies the authentication tag **Implementation approach:** @@ -783,7 +782,7 @@ In a deniable protocol, the authentication MAC can be computed by EITHER party, 2. Alternatively, implement a "triple DH" variant where you add a third DH: `DH5 = IK_A x IK_B`. This creates a shared secret that only requires Alice and Bob's identity keys. Since both parties can compute it, a transcript of messages authenticated with this secret is not proof against either party. -3. The associated data modification at `x3dh_manager.py:272` and the corresponding line at `x3dh_manager.py:341` must change in tandem (sender and receiver must produce the same associated data). +3. The associated data modification at `frontend/src/crypto/x3dh.ts` and the corresponding line at `frontend/src/crypto/x3dh.ts` must change in tandem (sender and receiver must produce the same associated data). **How to verify deniability:** @@ -810,31 +809,31 @@ WhatsApp solved this with Google Drive/iCloud backups encrypted with a user pass **Planning questions you must answer before writing code:** 1. What key material needs to be backed up? - - Identity keys (X25519 + Ed25519 private keys) - - Active ratchet states for each conversation - - Signed prekey private keys - - Message history (optional, large) + - Identity keys (X25519 + Ed25519 private keys) + - Active ratchet states for each conversation + - Signed prekey private keys + - Message history (optional, large) 2. How is the backup encrypted? - - Option A: User PIN/password -> Argon2id -> AES-256-GCM key - - Option B: Random backup key displayed as a 24-word recovery phrase - - Option C: Server-side HSM with rate limiting (Signal's SVR approach, requires hardware) + - Option A: User PIN/password -> Argon2id -> AES-256-GCM key + - Option B: Random backup key displayed as a 24-word recovery phrase + - Option C: Server-side HSM with rate limiting (Signal's SVR approach, requires hardware) 3. Where is the backup stored? - - Server-side (encrypted blob in PostgreSQL or object storage) - - User-exported file (like Signal's plaintext export, but encrypted) + - Server-side (encrypted blob in PostgreSQL or object storage) + - User-exported file (like Signal's plaintext export, but encrypted) 4. How do you handle backup key loss? - - Unrecoverable by design (most secure) - - Social recovery (N-of-M trusted contacts must approve, like Shamir's Secret Sharing) - - Server-side recovery with identity verification (weakest, requires trusting the server) + - Unrecoverable by design (most secure) + - Social recovery (N-of-M trusted contacts must approve, like Shamir's Secret Sharing) + - Server-side recovery with identity verification (weakest, requires trusting the server) **Where to start reading:** -- `frontend/src/crypto/key-store.ts:85-99` saves identity keys to IndexedDB. This is the data that needs backing up. -- `frontend/src/crypto/key-store.ts:353-358` saves ratchet states -- `frontend/src/crypto/key-store.ts:392-412` `clearAllKeys()` deletes everything on logout -- `frontend/src/crypto/message-store.ts:61-67` saves decrypted messages +- `frontend/src/crypto/key-store.ts` saves identity keys to IndexedDB. This is the data that needs backing up. +- `frontend/src/crypto/key-store.ts` saves ratchet states +- `frontend/src/crypto/key-store.ts` `clearAllKeys` deletes everything on logout +- `frontend/src/crypto/message-store.ts` saves decrypted messages **Implementation phases:** @@ -843,28 +842,28 @@ WhatsApp solved this with Google Drive/iCloud backups encrypted with a user pass Define a JSON schema for the backup: ```json { - "version": 1, - "created_at": "2025-01-15T14:30:00Z", - "identity_keys": { - "x25519_private": "", - "x25519_public": "", - "ed25519_private": "", - "ed25519_public": "" - }, - "ratchet_states": [ - { - "peer_id": "bob-uuid", - "root_key": "", - "sending_chain_key": "", - "receiving_chain_key": "", - "dh_private_key": "", - "dh_peer_public_key": "", - "sending_message_number": 42, - "receiving_message_number": 37, - "previous_sending_chain_length": 12 - } - ], - "messages": [] + "version": 1, + "created_at": "2025-01-15T14:30:00Z", + "identity_keys": { + "x25519_private": "", + "x25519_public": "", + "ed25519_private": "", + "ed25519_public": "" + }, + "ratchet_states": [ + { + "peer_id": "bob-uuid", + "root_key": "", + "sending_chain_key": "", + "receiving_chain_key": "", + "dh_private_key": "", + "dh_peer_public_key": "", + "sending_message_number": 42, + "receiving_message_number": 37, + "previous_sending_chain_length": 12 + } + ], + "messages": [] } ``` @@ -935,7 +934,7 @@ Build a corporate messaging system with compliance features (backup and recovery ### Optimize Ratchet State Storage -The current implementation serializes the entire ratchet state to PostgreSQL on every single message. Look at `message_service.py:208-267` (`_save_ratchet_state_to_db`). Every call to `send_encrypted_message` or `decrypt_received_message` triggers a full ratchet state write with `await session.commit()` at line 258. +The current implementation serializes the entire ratchet state to PostgreSQL on every single message. Look at `backend/app/services/message_service.py` (`_save_ratchet_state_to_db`). Every call to `send_encrypted_message` or `decrypt_received_message` triggers a full ratchet state write with `await session.commit` . This is a bottleneck. For a conversation with rapid back-and-forth messaging, you are writing to PostgreSQL twice per message (once for encrypt, once for decrypt). @@ -945,7 +944,7 @@ This is a bottleneck. For a conversation with rapid back-and-forth messaging, yo 1. On first ratchet state load, cache it in Redis with a key like `ratchet:{user_id}:{peer_user_id}` 2. After each ratchet advance, write to Redis only (sub-millisecond) 3. Periodically (every 10 messages or every 30 seconds) flush to PostgreSQL (durable storage) -4. On WebSocket disconnect (`websocket_manager.py:97-133`), flush all cached states to PostgreSQL +4. On WebSocket disconnect (`websocket_manager.py`), flush all cached states to PostgreSQL 5. On startup, load from PostgreSQL (Redis is not durable across restarts) **Measurement:** Benchmark messages per second before and after with a simple load test. Target: greater than 100 messages per second per conversation. @@ -955,27 +954,27 @@ This is a bottleneck. For a conversation with rapid back-and-forth messaging, yo **Detailed profiling steps:** 1. Add timing instrumentation around `_save_ratchet_state_to_db`: - ```python - import time - start = time.perf_counter() - await session.commit() - elapsed_ms = (time.perf_counter() - start) * 1000 - logger.info("Ratchet state save: %.2fms", elapsed_ms) - ``` + ```python + import time + start = time.perf_counter + await session.commit + elapsed_ms = (time.perf_counter - start) * 1000 + logger.info("Ratchet state save: %.2fms", elapsed_ms) + ``` 2. Run a load test: two users exchanging 1000 messages as fast as possible. Measure: - - Average time per `_save_ratchet_state_to_db` call - - Average time per `_load_ratchet_state_from_db` call - - Total messages per second (end-to-end) - - PostgreSQL connection pool utilization (`DB_POOL_SIZE` at `config.py:118`) + - Average time per `_save_ratchet_state_to_db` call + - Average time per `_load_ratchet_state_from_db` call + - Total messages per second (end-to-end) + - PostgreSQL connection pool utilization (`DB_POOL_SIZE` at `config.py`) 3. Implement Redis caching, repeat the same load test, compare. **Redis cache schema:** ``` -Key: ratchet:{user_id}:{peer_user_id} +Key: ratchet:{user_id}:{peer_user_id} Value: JSON serialized ratchet state -TTL: 300 seconds (auto-expire as safety net) +TTL: 300 seconds (auto-expire as safety net) ``` On each ratchet advance, write to Redis. Every 10 messages OR every 30 seconds, flush to PostgreSQL. On disconnect, flush immediately. @@ -990,13 +989,13 @@ Use `locust` or `k6` to load test the WebSocket layer. Determine how many concur 1. Maximum concurrent WebSocket connections before memory exhaustion 2. Message throughput: messages per second at 100, 500, 1000, 5000 concurrent connections 3. Latency: p50, p95, p99 message delivery latency at various connection counts -4. Identify the bottleneck: Is it CPU (from encryption operations)? Memory (from connection pool at `websocket_manager.py:39`)? I/O (from SurrealDB writes)? Python GIL contention? +4. Identify the bottleneck: Is it CPU (from encryption operations)? Memory (from connection pool at `websocket_manager.py`)? I/O (from SurrealDB writes)? Python GIL contention? **Target:** Document the breaking point and propose a horizontal scaling strategy. **Scaling considerations:** -The current architecture has a single `ConnectionManager` instance at `websocket_manager.py:296` holding all WebSocket connections in memory (`self.active_connections: dict[UUID, list[WebSocket]]`). This does not scale horizontally because connections on Server A are invisible to Server B. +The current architecture has a single `ConnectionManager` instance at `websocket_manager.py` holding all WebSocket connections in memory (`self.active_connections: dict[UUID, list[WebSocket]]`). This does not scale horizontally because connections on Server A are invisible to Server B. To scale to multiple server instances: 1. Use Redis Pub/Sub for cross-instance message routing. When Server A needs to send a message to a user connected to Server B, it publishes to a Redis channel. Server B subscribes and forwards to the local WebSocket. @@ -1007,22 +1006,25 @@ To scale to multiple server instances: **Load test script outline (k6):** ```javascript import ws from 'k6/ws'; -export default function () { - const url = 'ws://localhost:8000/ws?user_id=...'; - const res = ws.connect(url, {}, function (socket) { - socket.on('open', () => { - socket.send(JSON.stringify({ - type: 'encrypted_message', - recipient_id: '...', - room_id: '...', - ciphertext: '...', - nonce: '...', - header: '...' - })); - }); - socket.on('message', (msg) => {}); - socket.setTimeout(() => socket.close(), 30000); - }); +export default function { + // The server expects a session cookie; load tests should sign in first + // and pass the cookie via params.headers.Cookie. + const url = 'ws://localhost:8000/ws'; + const params = { headers: { Cookie: 'chat_session=' } }; + const res = ws.connect(url, params, function (socket) { + socket.on('open', => { + socket.send(JSON.stringify({ + type: 'encrypted_message', + recipient_id: '...', + room_id: '...', + ciphertext: '...', + nonce: '...', + header: '...' + })); + }); + socket.on('message', (msg) => {}); + socket.setTimeout( => socket.close, 30000); + }); } ``` @@ -1038,7 +1040,7 @@ Run with: `k6 run --vus 100 --duration 60s load_test.js` Build a system where identity key changes are logged to a verifiable append-only log, similar to Certificate Transparency for TLS certificates. This prevents the server from silently swapping identity keys to perform a man-in-the-middle attack. -Currently, the server at `prekey_service.py:293-361` serves prekey bundles. A compromised server could serve a fake identity key (its own), perform X3DH with both parties, and relay decrypted messages. The users would not know. +Currently, the server at `backend/app/services/prekey_service.py` serves prekey bundles. A compromised server could serve a fake identity key (its own), perform X3DH with both parties, and relay decrypted messages. The users would not know. A key transparency log makes this detectable: every identity key is logged, and clients can audit the log to verify their key has not been replaced. @@ -1062,13 +1064,13 @@ Perform a manual security audit of the X3DH and Double Ratchet implementations. **Audit checklist:** -1. **HKDF parameter usage** (`x3dh_manager.py:258-264`): Is the salt correct? Is the info string distinct between different uses? Are the output lengths appropriate? +1. **HKDF parameter usage** (`frontend/src/crypto/x3dh.ts`): Is the salt correct? Is the info string distinct between different uses? Are the output lengths appropriate? -2. **Nonce generation** (`double_ratchet.py:122`): Is `os.urandom(AES_GCM_NONCE_SIZE)` called for every encryption? Is there any risk of nonce reuse? +2. **Nonce generation** (`frontend/src/crypto/double-ratchet.ts`): Is `os.urandom(AES_GCM_NONCE_SIZE)` called for every encryption? Is there any risk of nonce reuse? -3. **WebCrypto API usage** (`primitives.ts:224-259`): Is the `iv` parameter always fresh? Are `additionalData` bindings correct? Are key usages properly restricted? +3. **WebCrypto API usage** (`frontend/src/crypto/primitives.ts`): Is the `iv` parameter always fresh? Are `additionalData` bindings correct? Are key usages properly restricted? -4. **Timing side channels**: Look at `primitives.ts:388-397` (`constantTimeEqual`). Is this actually constant time in JavaScript? (Spoiler: the `if (a.length !== b.length) return false` on line 389 is an early return that leaks length information. Is this a problem in context?) +4. **Timing side channels**: Look at `frontend/src/crypto/primitives.ts` (`constantTimeEqual`). Is this actually constant time in JavaScript? (Spoiler: the `if (a.length !== b.length) return false` is an early return that leaks length information. Is this a problem in context?) 5. **Random number generation**: Verify that all randomness comes from `os.urandom` (Python) or `crypto.getRandomValues` (JavaScript). Search for any use of `random` or `Math.random`. @@ -1082,7 +1084,7 @@ Write a report documenting your findings. Build a system where the server does not know who sent a message, only who it is for. Signal implemented this as "Sealed Sender." -Currently, the WebSocket endpoint at `websocket.py:25-84` receives messages from authenticated users. The server knows `sender_id` (from the WebSocket connection) and `recipient_id` (from the message payload). It stores both in SurrealDB (`message_service.py:292-302`). +Currently, the WebSocket endpoint at `websocket.py` receives messages from authenticated users. The server knows `sender_id` (from the WebSocket connection) and `recipient_id` (from the message payload). It stores both in SurrealDB (`backend/app/services/message_service.py`). With Sealed Sender, the sender identity is encrypted inside the E2E encrypted payload. The server only sees the recipient (needed for routing). The sender is revealed only after the recipient decrypts. @@ -1097,22 +1099,22 @@ This requires the server to have its own keypair (separate from any user), and c 1. Generate a server X25519 keypair on startup. Publish the server's public key at a well-known endpoint: `GET /api/.well-known/server-key`. 2. Modify the client message sending flow: - - Construct the inner payload: the normal E2E encrypted message (ciphertext, nonce, header) PLUS `sender_id` in plaintext - - Encrypt the inner payload with the intended recipient's ratchet (as currently done) - - Construct the outer envelope: `{recipient_id: "bob-uuid", sealed_payload: ""}` - - Encrypt the outer envelope with the server's public key (simple X25519 + AES-GCM) - - Send only the outer ciphertext over the WebSocket + - Construct the inner payload: the normal E2E encrypted message (ciphertext, nonce, header) PLUS `sender_id` in plaintext + - Encrypt the inner payload with the intended recipient's ratchet (as currently done) + - Construct the outer envelope: `{recipient_id: "bob-uuid", sealed_payload: ""}` + - Encrypt the outer envelope with the server's public key (simple X25519 + AES-GCM) + - Send only the outer ciphertext over the WebSocket 3. Modify the server routing: - - Decrypt the outer envelope using the server's private key - - Read `recipient_id` from the decrypted envelope - - Forward `sealed_payload` to the recipient - - The server never sees `sender_id` + - Decrypt the outer envelope using the server's private key + - Read `recipient_id` from the decrypted envelope + - Forward `sealed_payload` to the recipient + - The server never sees `sender_id` 4. Modify the recipient decryption: - - Decrypt the sealed payload using the E2E ratchet - - Extract `sender_id` from the decrypted content - - Display the message with the correct sender attribution + - Decrypt the sealed payload using the E2E ratchet + - Extract `sender_id` from the decrypted content + - Display the message with the correct sender attribution **Limitation:** The server still knows the recipient (it needs to for routing). And it knows the sender's IP address and WebSocket connection. True sender anonymity requires additional measures like Tor or mix networks. Sealed Sender hides sender identity from the server's message logs, not from network-level observation. @@ -1133,17 +1135,17 @@ Use Hypothesis (Python) to write property-based tests for the cryptographic impl **Properties to verify:** -1. **X3DH symmetry:** For any two valid identity key pairs and any valid prekey bundle, `perform_x3dh_sender` and `perform_x3dh_receiver` produce the same `shared_key`. Reference: `x3dh_manager.py:208-281` and `x3dh_manager.py:283-350`. +1. **X3DH symmetry:** For any two valid identity key pairs and any valid prekey bundle, `perform_x3dh_sender` and `perform_x3dh_receiver` produce the same `shared_key`. Reference: `frontend/src/crypto/x3dh.ts` and `frontend/src/crypto/x3dh.ts`. -2. **Double Ratchet round-trip:** For any sequence of plaintext messages in any order, encrypting with `encrypt_message` and decrypting with `decrypt_message` always returns the original plaintext. Reference: `double_ratchet.py:323-362` and `double_ratchet.py:364-416`. +2. **Double Ratchet round-trip:** For any sequence of plaintext messages in any order, encrypting with `encrypt_message` and decrypting with `decrypt_message` always returns the original plaintext. Reference: `frontend/src/crypto/double-ratchet.ts` and `frontend/src/crypto/double-ratchet.ts`. -3. **AES-GCM round-trip:** For any plaintext and any valid key, `aesGcmEncrypt` followed by `aesGcmDecrypt` returns the original plaintext. Reference: `primitives.ts:224-292`. +3. **AES-GCM round-trip:** For any plaintext and any valid key, `aesGcmEncrypt` followed by `aesGcmDecrypt` returns the original plaintext. Reference: `frontend/src/crypto/primitives.ts`. 4. **No nonce reuse:** For N encryptions with the same key, all N nonces are distinct. (This should hold with overwhelming probability for random 12-byte nonces, but verify it empirically for N=10000.) 5. **Forward secrecy:** After advancing the ratchet, old message keys cannot be derived from the new state. Generate a ratchet state, encrypt a message, advance the ratchet 100 steps, then verify that the message key from step 0 cannot be recomputed from the state at step 100. -6. **Out-of-order decryption:** For any permutation of N messages, decrypting them in that permuted order produces the same set of plaintexts as decrypting in the original order. This tests the skipped message key logic at `double_ratchet.py:215-244`. +6. **Out-of-order decryption:** For any permutation of N messages, decrypting them in that permuted order produces the same set of plaintexts as decrypting in the original order. This tests the skipped message key logic at `frontend/src/crypto/double-ratchet.ts`. **Hypothesis strategy example:** @@ -1151,18 +1153,18 @@ Use Hypothesis (Python) to write property-based tests for the cryptographic impl from hypothesis import given, strategies as st @given( - message_count=st.integers(min_value=1, max_value=50), - delivery_order=st.permutations(range(50)) # random permutation + message_count=st.integers(min_value=1, max_value=50), + delivery_order=st.permutations(range(50)) # random permutation ) def test_out_of_order_delivery(message_count, delivery_order): - """All messages decrypt correctly regardless of delivery order.""" - # Generate N messages - # Encrypt them in order (advancing the sending ratchet) - # Decrypt them in the shuffled order - # Assert all plaintexts match the originals + """All messages decrypt correctly regardless of delivery order.""" + # Generate N messages + # Encrypt them in order (advancing the sending ratchet) + # Decrypt them in the shuffled order + # Assert all plaintexts match the originals ``` -**Where to put the tests:** Create a `tests/` directory in the backend and structure tests as `tests/test_x3dh.py`, `tests/test_double_ratchet.py`, `tests/test_crypto_properties.py`. +**Where to put the tests:** The crypto property tests belong in the frontend Vitest suite next to `x3dh.test.ts` and `double-ratchet.test.ts`. Add a new file like `frontend/src/crypto/properties.test.ts` for randomized round-trip and tamper checks. Reserve `backend/tests/` for backend integration tests (auth, prekey storage, session enforcement). ---