fix(encrypted-p2p-chat): apply audit findings — true E2EE, session auth, spec-correct crypto
This commit applies the actionable plan from docs/plans/2026-04-29-encrypted-p2p-chat-audit-fixes.md.
Backend
- Delete server-side X3DH/Double Ratchet (`app/core/encryption/*`); the server is now an opaque relay for client-encrypted messages.
- Drop `private_key` columns from IdentityKey/SignedPrekey/OneTimePrekey; prekey-bundle endpoint serves only public material.
- Remove RatchetState and SkippedMessageKey models and the deprecated server-encryption code path in message_service.
- Fix the broken `not OneTimePrekey.is_used` SQL filter (`Column.is_(False)` instead of Python `not`); repair the silent OPK-replenish amplification by deleting the server-generated path entirely.
- Stop calling `prekey_service.initialize_user_keys` from registration/login. Server never holds private key material again.
- Add session-cookie auth: opaque token issued in Redis on register/auth complete, `current_user` dependency on every protected route, `/auth/me`, `/auth/logout`. WebSocket authenticates via the same cookie.
- Add room membership checks to WS encrypted_message + typing handlers, GET/DELETE /rooms/{id}, GET /rooms/{id}/messages. broadcast_to_room now filters by participants, not presence.
- Add slowapi rate limits to register/auth/search; per-user sliding-window cap on WS messages.
- WebAuthn: replace username with a 64-byte random `webauthn_user_handle` for `user.id`; fix `backup_eligible` to read `credential_backup_eligible`; switch UserVerificationRequirement to REQUIRED; key authentication challenges by challenge bytes (no more "discoverable" collision).
- Implement GET /rooms/{id} and DELETE /rooms/{id} (cascade delete with ownership check) and fix the N+1 in list_rooms by batching User lookups.
- Remove broken/obsolete tests; add regression tests for the OPK filter bug and session-protected endpoints (401 without cookie).
- Sanitize WebSocket error responses; correlation IDs replace stack-trace leaks.
Frontend
- X3DH: prepend the 32-byte 0xff F prefix to the HKDF input per spec section 2.2.
- Double Ratchet: switch KDF_RK to spec form (HKDF salt=root_key, IKM=dh_output) and use 0x01/0x02 byte tags for KDF_CK.
- Replace base64 helpers in `crypto/primitives.ts` with the URL-safe codec from `lib/base64.ts` (single source of truth, no spread-stack hazard).
- Update auth.service to issue a session via cookies (no more dead token store), call `/auth/me` on app boot, clear crypto state on logout. Delete unused `session.store.ts`.
- Update room.service / Chat page to drop client-supplied user_id arguments; the cookie now identifies the caller.
- Better forward-secrecy UX text for messages predating the device.
- Add Vitest with X3DH and Double Ratchet round-trip + tamper-detection tests.
Tooling
- Add `slowapi>=0.1.9` to backend deps; add `vitest` to frontend devDeps with `pnpm test` script.
- justfile: `test-frontend`, `dev-reset`.
- Adjust `.gitignore` so the Python `lib/` rules no longer hide `frontend/src/lib`.
Migration: existing dev volumes hold private-key columns and KDF-incompatible ratchet states; run `just dev-reset` and re-register before hitting the new code path.
This commit is contained in:
parent
ee684e5307
commit
6fd5f3d393
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
Double Ratchet state model for per conversation encryption state
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import Field
|
||||
|
||||
from app.config import RATCHET_STATE_MAX_LENGTH
|
||||
from app.models.Base import BaseDBModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class RatchetState(BaseDBModel, table = True):
|
||||
"""
|
||||
Double Ratchet algorithm state for a conversation between two users
|
||||
"""
|
||||
__tablename__ = "ratchet_states"
|
||||
|
||||
id: int = Field(default = None, primary_key = True)
|
||||
|
||||
user_id: UUID = Field(
|
||||
foreign_key = "users.id",
|
||||
nullable = False,
|
||||
index = True
|
||||
)
|
||||
peer_user_id: UUID = Field(
|
||||
foreign_key = "users.id",
|
||||
nullable = False,
|
||||
index = True
|
||||
)
|
||||
|
||||
dh_private_key: str | None = Field(
|
||||
default = None,
|
||||
max_length = RATCHET_STATE_MAX_LENGTH
|
||||
)
|
||||
dh_public_key: str | None = Field(
|
||||
default = None,
|
||||
max_length = RATCHET_STATE_MAX_LENGTH
|
||||
)
|
||||
dh_peer_public_key: str | None = Field(
|
||||
default = None,
|
||||
max_length = RATCHET_STATE_MAX_LENGTH
|
||||
)
|
||||
|
||||
root_key: str = Field(nullable = False, max_length = RATCHET_STATE_MAX_LENGTH)
|
||||
sending_chain_key: str = Field(
|
||||
nullable = False,
|
||||
max_length = RATCHET_STATE_MAX_LENGTH
|
||||
)
|
||||
receiving_chain_key: str = Field(
|
||||
nullable = False,
|
||||
max_length = RATCHET_STATE_MAX_LENGTH
|
||||
)
|
||||
|
||||
sending_message_number: int = Field(default = 0, nullable = False)
|
||||
receiving_message_number: int = Field(default = 0, nullable = False)
|
||||
previous_sending_chain_length: int = Field(default = 0, nullable = False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
String representation of RatchetState
|
||||
"""
|
||||
return f"<RatchetState user_id={self.user_id} peer={self.peer_user_id}>"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
Skipped message key storage for out of order Double Ratchet messages
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlmodel import Field
|
||||
|
||||
from app.models.Base import BaseDBModel
|
||||
from app.config import RATCHET_STATE_MAX_LENGTH
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class SkippedMessageKey(BaseDBModel, table = True):
|
||||
"""
|
||||
Stores message keys for out of order messages in Double Ratchet
|
||||
"""
|
||||
__tablename__ = "skipped_message_keys"
|
||||
|
||||
id: int = Field(default = None, primary_key = True)
|
||||
|
||||
ratchet_state_id: int = Field(
|
||||
foreign_key = "ratchet_states.id",
|
||||
nullable = False,
|
||||
index = True
|
||||
)
|
||||
|
||||
dh_public_key: str = Field(
|
||||
nullable = False,
|
||||
max_length = RATCHET_STATE_MAX_LENGTH,
|
||||
index = True
|
||||
)
|
||||
message_number: int = Field(nullable = False, index = True)
|
||||
|
||||
message_key: str = Field(
|
||||
nullable = False,
|
||||
max_length = RATCHET_STATE_MAX_LENGTH
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
String representation of SkippedMessageKey
|
||||
"""
|
||||
return (
|
||||
f"<SkippedMessageKey "
|
||||
f"ratchet_id={self.ratchet_state_id} "
|
||||
f"msg_num={self.message_number}>"
|
||||
)
|
||||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
Authentication service for user and credential management
|
||||
©AngelaMos | 2026
|
||||
auth_service.py
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
from datetime import UTC, datetime
|
||||
|
|
@ -28,15 +29,12 @@ from app.core.exceptions import (
|
|||
from app.core.passkey.passkey_manager import passkey_manager
|
||||
from app.core.redis_manager import redis_manager
|
||||
from app.models.Credential import Credential
|
||||
from app.models.IdentityKey import IdentityKey
|
||||
from app.models.User import User
|
||||
from app.services.prekey_service import prekey_service
|
||||
from app.schemas.auth import (
|
||||
AuthenticationBeginRequest,
|
||||
AuthenticationCompleteRequest,
|
||||
RegistrationBeginRequest,
|
||||
RegistrationCompleteRequest,
|
||||
UserResponse,
|
||||
VerifiedRegistration,
|
||||
)
|
||||
|
||||
|
|
@ -53,6 +51,7 @@ class AuthService:
|
|||
session: AsyncSession,
|
||||
username: str,
|
||||
display_name: str,
|
||||
webauthn_user_handle: bytes,
|
||||
) -> User:
|
||||
"""
|
||||
Create a new user with username uniqueness check
|
||||
|
|
@ -68,6 +67,7 @@ class AuthService:
|
|||
user = User(
|
||||
username = username,
|
||||
display_name = display_name,
|
||||
webauthn_user_handle = webauthn_user_handle,
|
||||
)
|
||||
|
||||
session.add(user)
|
||||
|
|
@ -332,8 +332,7 @@ class AuthService:
|
|||
self,
|
||||
session: AsyncSession,
|
||||
request: RegistrationBeginRequest,
|
||||
) -> dict[str,
|
||||
Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Begin WebAuthn passkey registration flow
|
||||
"""
|
||||
|
|
@ -349,25 +348,21 @@ class AuthService:
|
|||
)
|
||||
raise UserExistsError(f"Username {request.username} already exists")
|
||||
|
||||
user_id_bytes = request.username.encode()
|
||||
|
||||
exclude_credentials = []
|
||||
if existing_user:
|
||||
exclude_credentials = [
|
||||
base64url_to_bytes(cred.credential_id)
|
||||
for cred in existing_user.credentials
|
||||
]
|
||||
from app.config import WEBAUTHN_USER_HANDLE_BYTES
|
||||
user_handle = secrets.token_bytes(WEBAUTHN_USER_HANDLE_BYTES)
|
||||
|
||||
options_response = passkey_manager.generate_registration_options(
|
||||
user_id = user_id_bytes,
|
||||
user_id = user_handle,
|
||||
username = request.username,
|
||||
display_name = request.display_name,
|
||||
exclude_credentials = exclude_credentials,
|
||||
exclude_credentials = [],
|
||||
)
|
||||
|
||||
await redis_manager.set_registration_challenge(
|
||||
user_id = request.username,
|
||||
await redis_manager.set_registration_context(
|
||||
username = request.username,
|
||||
challenge = options_response.challenge,
|
||||
user_handle = user_handle,
|
||||
display_name = request.display_name,
|
||||
)
|
||||
|
||||
logger.info("Started registration for user: %s", request.username)
|
||||
|
|
@ -378,17 +373,17 @@ class AuthService:
|
|||
session: AsyncSession,
|
||||
request: RegistrationCompleteRequest,
|
||||
username: str,
|
||||
) -> UserResponse:
|
||||
) -> User:
|
||||
"""
|
||||
Complete WebAuthn passkey registration
|
||||
"""
|
||||
expected_challenge = await redis_manager.get_registration_challenge(
|
||||
user_id = username
|
||||
context = await redis_manager.take_registration_context(
|
||||
username = username
|
||||
)
|
||||
|
||||
if not expected_challenge:
|
||||
if context is None:
|
||||
logger.warning(
|
||||
"Registration challenge not found or expired for user: %s",
|
||||
"Registration context not found or expired for user: %s",
|
||||
username
|
||||
)
|
||||
raise ChallengeExpiredError(
|
||||
|
|
@ -398,7 +393,7 @@ class AuthService:
|
|||
try:
|
||||
verified = passkey_manager.verify_registration(
|
||||
credential = request.credential,
|
||||
expected_challenge = expected_challenge,
|
||||
expected_challenge = context["challenge"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Registration verification failed: %s", e)
|
||||
|
|
@ -409,8 +404,8 @@ class AuthService:
|
|||
user = await self.create_user(
|
||||
session = session,
|
||||
username = username,
|
||||
display_name = request.credential.get("displayName",
|
||||
username),
|
||||
display_name = context["display_name"],
|
||||
webauthn_user_handle = context["user_handle"],
|
||||
)
|
||||
|
||||
await self.store_credential(
|
||||
|
|
@ -420,28 +415,14 @@ class AuthService:
|
|||
device_name = request.device_name,
|
||||
)
|
||||
|
||||
await prekey_service.initialize_user_keys(
|
||||
session = session,
|
||||
user_id = user.id,
|
||||
)
|
||||
|
||||
logger.info("Registration completed for user: %s", username)
|
||||
|
||||
return UserResponse(
|
||||
id = str(user.id),
|
||||
username = user.username,
|
||||
display_name = user.display_name,
|
||||
is_active = user.is_active,
|
||||
is_verified = user.is_verified,
|
||||
created_at = user.created_at.isoformat(),
|
||||
)
|
||||
return user
|
||||
|
||||
async def begin_authentication(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
request: AuthenticationBeginRequest,
|
||||
) -> dict[str,
|
||||
Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Begin WebAuthn passkey authentication flow
|
||||
"""
|
||||
|
|
@ -453,43 +434,31 @@ class AuthService:
|
|||
username = request.username,
|
||||
)
|
||||
|
||||
if not user:
|
||||
logger.warning(
|
||||
"Authentication attempt for non-existent user: %s",
|
||||
request.username
|
||||
)
|
||||
raise UserNotFoundError("User not found")
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
"Authentication attempt for inactive user: %s",
|
||||
request.username
|
||||
)
|
||||
raise UserInactiveError("User account is inactive")
|
||||
|
||||
allow_credentials = [
|
||||
base64url_to_bytes(cred.credential_id)
|
||||
for cred in user.credentials
|
||||
]
|
||||
if user is not None and user.is_active:
|
||||
allow_credentials = [
|
||||
base64url_to_bytes(cred.credential_id)
|
||||
for cred in user.credentials
|
||||
]
|
||||
|
||||
options_response = passkey_manager.generate_authentication_options(
|
||||
allow_credentials = allow_credentials,
|
||||
)
|
||||
|
||||
user_id = request.username if request.username else "discoverable"
|
||||
await redis_manager.set_authentication_challenge(
|
||||
user_id = user_id,
|
||||
challenge = options_response.challenge,
|
||||
)
|
||||
|
||||
logger.info("Started authentication for user: %s", user_id)
|
||||
logger.info(
|
||||
"Started authentication (username hint: %s)",
|
||||
request.username or "<discoverable>"
|
||||
)
|
||||
return options_response.options
|
||||
|
||||
async def complete_authentication(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
request: AuthenticationCompleteRequest,
|
||||
) -> UserResponse:
|
||||
) -> User:
|
||||
"""
|
||||
Complete WebAuthn passkey authentication
|
||||
"""
|
||||
|
|
@ -497,6 +466,33 @@ class AuthService:
|
|||
if not credential_id:
|
||||
raise InvalidDataError("Missing credential ID")
|
||||
|
||||
client_data_b64 = request.credential.get("response", {}).get(
|
||||
"clientDataJSON"
|
||||
)
|
||||
if not client_data_b64:
|
||||
raise InvalidDataError("Missing clientDataJSON")
|
||||
|
||||
try:
|
||||
import json as _json
|
||||
client_data = _json.loads(
|
||||
base64url_to_bytes(client_data_b64).decode()
|
||||
)
|
||||
challenge_bytes = base64url_to_bytes(client_data["challenge"])
|
||||
except Exception as exc:
|
||||
logger.error("Failed to parse clientDataJSON: %s", exc)
|
||||
raise InvalidDataError("Malformed clientDataJSON") from exc
|
||||
|
||||
challenge_consumed = await redis_manager.take_authentication_challenge(
|
||||
challenge = challenge_bytes
|
||||
)
|
||||
if not challenge_consumed:
|
||||
logger.warning(
|
||||
"Authentication challenge invalid or expired"
|
||||
)
|
||||
raise ChallengeExpiredError(
|
||||
"Challenge expired or not found - please restart authentication"
|
||||
)
|
||||
|
||||
credential = await self.get_credential_by_id(
|
||||
session = session,
|
||||
credential_id = credential_id,
|
||||
|
|
@ -528,23 +524,10 @@ class AuthService:
|
|||
)
|
||||
raise UserInactiveError("User account is inactive")
|
||||
|
||||
expected_challenge = await redis_manager.get_authentication_challenge(
|
||||
user_id = user.username
|
||||
)
|
||||
|
||||
if not expected_challenge:
|
||||
logger.warning(
|
||||
"Authentication challenge not found for user: %s",
|
||||
user.username
|
||||
)
|
||||
raise ChallengeExpiredError(
|
||||
"Challenge expired or not found - please restart authentication"
|
||||
)
|
||||
|
||||
try:
|
||||
verified = passkey_manager.verify_authentication(
|
||||
credential = request.credential,
|
||||
expected_challenge = expected_challenge,
|
||||
expected_challenge = challenge_bytes,
|
||||
credential_public_key = base64url_to_bytes(credential.public_key),
|
||||
credential_current_sign_count = credential.sign_count,
|
||||
)
|
||||
|
|
@ -572,30 +555,8 @@ class AuthService:
|
|||
backup_eligible = verified.backup_eligible,
|
||||
)
|
||||
|
||||
ik_statement = select(IdentityKey).where(IdentityKey.user_id == user.id)
|
||||
ik_result = await session.execute(ik_statement)
|
||||
existing_ik = ik_result.scalar_one_or_none()
|
||||
|
||||
if not existing_ik:
|
||||
logger.info(
|
||||
"Initializing encryption keys for existing user: %s",
|
||||
user.username
|
||||
)
|
||||
await prekey_service.initialize_user_keys(
|
||||
session = session,
|
||||
user_id = user.id,
|
||||
)
|
||||
|
||||
logger.info("Authentication successful for user: %s", user.username)
|
||||
|
||||
return UserResponse(
|
||||
id = str(user.id),
|
||||
username = user.username,
|
||||
display_name = user.display_name,
|
||||
is_active = user.is_active,
|
||||
is_verified = user.is_verified,
|
||||
created_at = user.created_at.isoformat(),
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
auth_service = AuthService()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@
|
|||
"format": "biome format --write .",
|
||||
"format:prettier": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nanostores/persistent": "^1.3.3",
|
||||
|
|
@ -40,6 +42,7 @@
|
|||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-solid": "^2.11.10"
|
||||
"vite-plugin-solid": "^2.11.10",
|
||||
"vitest": "^2.1.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 498 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 985 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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"}
|
||||
|
|
@ -120,7 +120,8 @@ class CryptoService {
|
|||
)
|
||||
await saveSignedPreKey(this.userId, this.signedPreKey)
|
||||
|
||||
await api.encryption.rotateSignedPrekey(this.userId)
|
||||
const unusedOTPs = await getUnusedOneTimePreKeys(this.userId)
|
||||
await this.uploadPublicKeys(unusedOTPs)
|
||||
}
|
||||
|
||||
private async replenishOneTimePreKeys(): Promise<void> {
|
||||
|
|
@ -130,6 +131,7 @@ class CryptoService {
|
|||
DEFAULT_ONE_TIME_PREKEY_COUNT / 2
|
||||
)
|
||||
await saveOneTimePreKeys(this.userId, newPreKeys)
|
||||
await this.uploadPublicKeys(newPreKeys)
|
||||
}
|
||||
|
||||
private async uploadPublicKeys(oneTimePreKeys: OneTimePreKey[]): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -32,6 +32,7 @@ import {
|
|||
|
||||
const X3DH_INFO = new TextEncoder().encode('X3DH')
|
||||
const EMPTY_SALT = new Uint8Array(HKDF_OUTPUT_SIZE)
|
||||
const X3DH_F_PREFIX = new Uint8Array(32).fill(0xff)
|
||||
|
||||
export async function generateIdentityKeyPair(): Promise<IdentityKeyPair> {
|
||||
const x25519KeyPair = await generateX25519KeyPair()
|
||||
|
|
@ -173,7 +174,7 @@ export async function initiateX3DH(
|
|||
dhResults = [dh1, dh2, dh3]
|
||||
}
|
||||
|
||||
const concatenated = concatBytes(...dhResults)
|
||||
const concatenated = concatBytes(X3DH_F_PREFIX, ...dhResults)
|
||||
const sharedKey = await hkdfDerive(concatenated, EMPTY_SALT, X3DH_INFO, 32)
|
||||
|
||||
const senderIdentityPublic = base64ToBytes(identityKeyPair.x25519_public)
|
||||
|
|
@ -236,7 +237,7 @@ export async function receiveX3DH(
|
|||
dhResults = [dh1, dh2, dh3]
|
||||
}
|
||||
|
||||
const concatenated = concatBytes(...dhResults)
|
||||
const concatenated = concatBytes(X3DH_F_PREFIX, ...dhResults)
|
||||
const sharedKey = await hkdfDerive(concatenated, EMPTY_SALT, X3DH_INFO, 32)
|
||||
|
||||
return sharedKey
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.tsx
|
||||
// ===================
|
||||
import { Router } from '@solidjs/router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
|
||||
import { render } from 'solid-js/web'
|
||||
import App from './App'
|
||||
import { ToastContainer } from './components/UI/Toast'
|
||||
import './index.css'
|
||||
import { authService } from './services'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -21,6 +26,8 @@ if (root === null) {
|
|||
throw new Error('Root element not found')
|
||||
}
|
||||
|
||||
void authService.rehydrateSession()
|
||||
|
||||
render(
|
||||
() => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,266 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// api-client.ts
|
||||
// ===================
|
||||
|
||||
import { API_URL } from '../config'
|
||||
import type {
|
||||
ApiErrorResponse,
|
||||
AuthenticationBeginRequest,
|
||||
AuthenticationCompleteRequest,
|
||||
PreKeyBundle,
|
||||
RegistrationBeginRequest,
|
||||
RegistrationCompleteRequest,
|
||||
Room,
|
||||
User,
|
||||
} from '../types'
|
||||
import { ApiError } from '../types'
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
|
||||
interface RequestOptions {
|
||||
method?: HttpMethod
|
||||
body?: unknown
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface ApiClientConfig {
|
||||
baseUrl: string
|
||||
defaultHeaders: Record<string, string>
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ApiClientConfig = {
|
||||
baseUrl: API_URL,
|
||||
defaultHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
const { method = 'GET', body, headers = {}, signal } = options
|
||||
|
||||
const url = `${DEFAULT_CONFIG.baseUrl}${endpoint}`
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
...DEFAULT_CONFIG.defaultHeaders,
|
||||
...headers,
|
||||
},
|
||||
signal,
|
||||
credentials: 'include',
|
||||
}
|
||||
|
||||
if (body !== undefined && method !== 'GET') {
|
||||
fetchOptions.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData: unknown
|
||||
|
||||
try {
|
||||
errorData = await response.json()
|
||||
} catch {
|
||||
errorData = { detail: response.statusText }
|
||||
}
|
||||
|
||||
const getErrorMessage = (): string => {
|
||||
if (
|
||||
typeof errorData === 'object' &&
|
||||
errorData !== null &&
|
||||
'detail' in errorData
|
||||
) {
|
||||
const detail = (errorData as ApiErrorResponse).detail
|
||||
return typeof detail === 'string'
|
||||
? detail
|
||||
: `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
return `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
|
||||
throw new ApiError(response.status, errorData, getErrorMessage())
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function get<T>(endpoint: string, signal?: AbortSignal): Promise<T> {
|
||||
return request<T>(endpoint, { method: 'GET', signal })
|
||||
}
|
||||
|
||||
async function post<T>(
|
||||
endpoint: string,
|
||||
body?: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
return request<T>(endpoint, { method: 'POST', body, signal })
|
||||
}
|
||||
|
||||
async function put<T>(
|
||||
endpoint: string,
|
||||
body?: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
return request<T>(endpoint, { method: 'PUT', body, signal })
|
||||
}
|
||||
|
||||
async function del<T>(endpoint: string, signal?: AbortSignal): Promise<T> {
|
||||
return request<T>(endpoint, { method: 'DELETE', signal })
|
||||
}
|
||||
|
||||
export interface RootResponse {
|
||||
app: string
|
||||
version: string
|
||||
status: string
|
||||
environment: string
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface WebAuthnOptionsResponse
|
||||
extends PublicKeyCredentialCreationOptions,
|
||||
PublicKeyCredentialRequestOptions {}
|
||||
|
||||
export interface UploadKeysRequest {
|
||||
identity_key: string
|
||||
identity_key_ed25519: string
|
||||
signed_prekey: string
|
||||
signed_prekey_signature: string
|
||||
one_time_prekeys: string[]
|
||||
}
|
||||
|
||||
export interface UploadKeysResponse {
|
||||
status: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CreateRoomRequest {
|
||||
participant_id: string
|
||||
room_type?: 'direct' | 'group' | 'ephemeral'
|
||||
}
|
||||
|
||||
export interface RoomListResponse {
|
||||
rooms: Room[]
|
||||
}
|
||||
|
||||
export interface UserSearchRequest {
|
||||
query: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface UserSearchResponse {
|
||||
users: User[]
|
||||
}
|
||||
|
||||
export const api = {
|
||||
root: {
|
||||
getStatus: (): Promise<RootResponse> => get<RootResponse>('/'),
|
||||
|
||||
getHealth: (): Promise<HealthResponse> => get<HealthResponse>('/health'),
|
||||
},
|
||||
|
||||
users: {
|
||||
search: (
|
||||
data: UserSearchRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<UserSearchResponse> =>
|
||||
post<UserSearchResponse>('/auth/users/search', data, signal),
|
||||
},
|
||||
|
||||
auth: {
|
||||
beginRegistration: (
|
||||
data: RegistrationBeginRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<WebAuthnOptionsResponse> =>
|
||||
post<WebAuthnOptionsResponse>('/auth/register/begin', data, signal),
|
||||
|
||||
completeRegistration: (
|
||||
data: RegistrationCompleteRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<User> => post<User>('/auth/register/complete', data, signal),
|
||||
|
||||
beginAuthentication: (
|
||||
data: AuthenticationBeginRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<WebAuthnOptionsResponse> =>
|
||||
post<WebAuthnOptionsResponse>('/auth/authenticate/begin', data, signal),
|
||||
|
||||
completeAuthentication: (
|
||||
data: AuthenticationCompleteRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<User> => post<User>('/auth/authenticate/complete', data, signal),
|
||||
|
||||
me: (signal?: AbortSignal): Promise<User> => get<User>('/auth/me', signal),
|
||||
|
||||
logout: (signal?: AbortSignal): Promise<undefined> =>
|
||||
post<undefined>('/auth/logout', undefined, signal),
|
||||
},
|
||||
|
||||
encryption: {
|
||||
getPrekeyBundle: (
|
||||
userId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<PreKeyBundle> =>
|
||||
get<PreKeyBundle>(`/encryption/prekey-bundle/${userId}`, signal),
|
||||
|
||||
uploadKeys: (
|
||||
userId: string,
|
||||
keys: UploadKeysRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<UploadKeysResponse> =>
|
||||
post<UploadKeysResponse>(`/encryption/upload-keys/${userId}`, keys, signal),
|
||||
},
|
||||
|
||||
rooms: {
|
||||
list: (signal?: AbortSignal): Promise<RoomListResponse> =>
|
||||
get<RoomListResponse>('/rooms', signal),
|
||||
|
||||
create: (data: CreateRoomRequest, signal?: AbortSignal): Promise<Room> =>
|
||||
post<Room>('/rooms', data, signal),
|
||||
|
||||
get: (roomId: string, signal?: AbortSignal): Promise<Room> =>
|
||||
get<Room>(`/rooms/${encodeURIComponent(roomId)}`, signal),
|
||||
|
||||
delete: (roomId: string, signal?: AbortSignal): Promise<undefined> =>
|
||||
del<undefined>(`/rooms/${encodeURIComponent(roomId)}`, signal),
|
||||
|
||||
getMessages: (
|
||||
roomId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
signal?: AbortSignal
|
||||
): Promise<{
|
||||
messages: Array<{
|
||||
id: string
|
||||
room_id: string
|
||||
sender_id: string
|
||||
sender_username: string
|
||||
ciphertext: string
|
||||
nonce: string
|
||||
header: string
|
||||
created_at: string
|
||||
}>
|
||||
has_more: boolean
|
||||
}> =>
|
||||
get(
|
||||
`/rooms/${encodeURIComponent(roomId)}/messages?limit=${limit}&offset=${offset}`,
|
||||
signal
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export { request, get, post, put, del }
|
||||
export type { RequestOptions, ApiClientConfig }
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
export * from './api-client'
|
||||
export * from './base64'
|
||||
export * from './date'
|
||||
export * from './validators'
|
||||
|
|
@ -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, '"')
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* WebAuthn authentication service
|
||||
* Handles passkey registration and authentication flows
|
||||
*/
|
||||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// auth.service.ts
|
||||
// ===================
|
||||
|
||||
import { cryptoService } from '../crypto'
|
||||
import { api } from '../lib/api-client'
|
||||
|
|
@ -192,7 +192,25 @@ export async function login(username?: string): Promise<User> {
|
|||
return user
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
export async function rehydrateSession(): Promise<User | null> {
|
||||
try {
|
||||
const user = await api.auth.me()
|
||||
setCurrentUser(user)
|
||||
await cryptoService.initialize(user.id)
|
||||
return user
|
||||
} catch {
|
||||
storeLogout()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
await api.auth.logout()
|
||||
} catch {
|
||||
/* ignore network errors during logout */
|
||||
}
|
||||
await cryptoService.clearAllSessions()
|
||||
storeLogout()
|
||||
}
|
||||
|
||||
|
|
@ -236,6 +254,7 @@ export const authService = {
|
|||
register,
|
||||
login,
|
||||
logout,
|
||||
rehydrateSession,
|
||||
isWebAuthnSupported,
|
||||
isPlatformAuthenticatorAvailable,
|
||||
isConditionalUIAvailable,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* ©AngelaMos | 2025
|
||||
* room.service.ts
|
||||
*/
|
||||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// room.service.ts
|
||||
// ===================
|
||||
|
||||
import {
|
||||
cryptoService,
|
||||
|
|
@ -19,9 +19,9 @@ import {
|
|||
} from '../stores'
|
||||
import type { Message, Room } from '../types'
|
||||
|
||||
export async function loadRooms(userId: string): Promise<Room[]> {
|
||||
export async function loadRooms(): Promise<Room[]> {
|
||||
try {
|
||||
const response = await api.rooms.list(userId)
|
||||
const response = await api.rooms.list()
|
||||
setRooms(response.rooms)
|
||||
return response.rooms
|
||||
} catch {
|
||||
|
|
@ -30,13 +30,11 @@ export async function loadRooms(userId: string): Promise<Room[]> {
|
|||
}
|
||||
|
||||
export async function createRoom(
|
||||
creatorId: string,
|
||||
participantId: string,
|
||||
roomType: 'direct' | 'group' | 'ephemeral' = 'direct'
|
||||
): Promise<Room | null> {
|
||||
try {
|
||||
const room = await api.rooms.create({
|
||||
creator_id: creatorId,
|
||||
participant_id: participantId,
|
||||
room_type: roomType,
|
||||
})
|
||||
|
|
@ -64,7 +62,6 @@ export async function loadMessages(
|
|||
const serverMessages = response.messages.reverse()
|
||||
|
||||
const newMessages: Message[] = []
|
||||
|
||||
const currentUserId = $userId.get()
|
||||
|
||||
for (const msg of serverMessages) {
|
||||
|
|
@ -72,7 +69,7 @@ export async function loadMessages(
|
|||
continue
|
||||
}
|
||||
|
||||
let content = '[Encrypted - from another session]'
|
||||
let content = '[Sent before this device joined - cannot decrypt]'
|
||||
const isOwnMessage = msg.sender_id === currentUserId
|
||||
|
||||
if (isOwnMessage) {
|
||||
|
|
@ -80,7 +77,7 @@ export async function loadMessages(
|
|||
if (localCopy) {
|
||||
content = localCopy.content
|
||||
} else {
|
||||
content = '[Your message - not stored locally]'
|
||||
content = '[Sent from a different device]'
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
|
|
@ -91,7 +88,7 @@ export async function loadMessages(
|
|||
msg.header
|
||||
)
|
||||
} catch {
|
||||
content = '[Encrypted - from another session]'
|
||||
content = '[Sent before this device joined - cannot decrypt]'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,10 +107,7 @@ export async function loadMessages(
|
|||
updated_at: msg.created_at,
|
||||
}
|
||||
|
||||
if (
|
||||
!content.startsWith('[Encrypted') &&
|
||||
!content.startsWith('[Your message')
|
||||
) {
|
||||
if (!content.startsWith('[Sent')) {
|
||||
void saveDecryptedMessage(decryptedMessage)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
/**
|
||||
* Session tokens store with persistence
|
||||
* Stores authentication tokens in localStorage
|
||||
*/
|
||||
|
||||
import { persistentAtom, persistentMap } from '@nanostores/persistent'
|
||||
|
||||
interface SessionTokens {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const DEFAULT_TOKENS: SessionTokens = {
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
expiresAt: 0,
|
||||
}
|
||||
|
||||
export const $sessionTokens = persistentMap<SessionTokens>(
|
||||
'chat:session:',
|
||||
DEFAULT_TOKENS,
|
||||
{
|
||||
encode: JSON.stringify,
|
||||
decode: JSON.parse,
|
||||
}
|
||||
)
|
||||
|
||||
export const $lastActivity = persistentAtom<string>('chat:last_activity', '', {
|
||||
encode: String,
|
||||
decode: String,
|
||||
})
|
||||
|
||||
export function setSessionTokens(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
expiresInSeconds: number
|
||||
): void {
|
||||
const expiresAt = Date.now() + expiresInSeconds * 1000
|
||||
$sessionTokens.set({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
})
|
||||
updateLastActivity()
|
||||
}
|
||||
|
||||
export function clearSessionTokens(): void {
|
||||
$sessionTokens.set(DEFAULT_TOKENS)
|
||||
}
|
||||
|
||||
export function isSessionValid(): boolean {
|
||||
const tokens = $sessionTokens.get()
|
||||
if (tokens.accessToken === '') {
|
||||
return false
|
||||
}
|
||||
return tokens.expiresAt > Date.now()
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
if (!isSessionValid()) {
|
||||
return null
|
||||
}
|
||||
return $sessionTokens.get().accessToken
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | null {
|
||||
const tokens = $sessionTokens.get()
|
||||
return tokens.refreshToken !== '' ? tokens.refreshToken : null
|
||||
}
|
||||
|
||||
export function updateLastActivity(): void {
|
||||
$lastActivity.set(new Date().toISOString())
|
||||
}
|
||||
|
||||
export function getLastActivity(): Date | null {
|
||||
const activity = $lastActivity.get()
|
||||
return activity !== '' ? new Date(activity) : null
|
||||
}
|
||||
|
|
@ -94,11 +94,19 @@ const errorMessageHandler: MessageHandler<ErrorMessageWS> = (message) => {
|
|||
}
|
||||
|
||||
const roomCreatedHandler: MessageHandler<RoomCreatedWS> = (message) => {
|
||||
const participants: Room['participants'] = message.participants.map((p) => ({
|
||||
user_id: String(p.user_id ?? ''),
|
||||
username: String(p.username ?? ''),
|
||||
display_name: String(p.display_name ?? ''),
|
||||
role: (p.role as 'owner' | 'admin' | 'member' | undefined) ?? 'member',
|
||||
joined_at: String(p.joined_at ?? ''),
|
||||
}))
|
||||
|
||||
const room: Room = {
|
||||
id: message.room_id,
|
||||
type: message.room_type as 'direct' | 'group' | 'ephemeral',
|
||||
name: message.name,
|
||||
participants: message.participants,
|
||||
name: message.name ?? undefined,
|
||||
participants,
|
||||
unread_count: 0,
|
||||
is_encrypted: message.is_encrypted,
|
||||
created_at: message.created_at,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue