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 00000000..16ed2737 Binary files /dev/null and b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/android-chrome-192x192.png differ 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 00000000..39db81a4 Binary files /dev/null and b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/android-chrome-512x512.png differ diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/apple-touch-icon.png b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/apple-touch-icon.png new file mode 100644 index 00000000..f2bf6e30 Binary files /dev/null and b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/apple-touch-icon.png differ 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 00000000..efcdaf8c Binary files /dev/null and b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/favicon-16x16.png differ 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 00000000..56a96def Binary files /dev/null and b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/favicon-32x32.png differ 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 00000000..2769aab5 Binary files /dev/null and b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/favicon.ico differ diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/site.webmanifest b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/site.webmanifest new file mode 100644 index 00000000..1dd91123 --- /dev/null +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/public/assets/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} diff --git a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/crypto-service.ts b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/crypto-service.ts index b984d971..6249a3b5 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/crypto-service.ts +++ b/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/crypto-service.ts @@ -120,7 +120,8 @@ class CryptoService { ) await saveSignedPreKey(this.userId, this.signedPreKey) - await api.encryption.rotateSignedPrekey(this.userId) + const unusedOTPs = await getUnusedOneTimePreKeys(this.userId) + await this.uploadPublicKeys(unusedOTPs) } private async replenishOneTimePreKeys(): Promise { @@ -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