diff --git a/.cursor/rules/ui.mdc b/.cursor/rules/ui.mdc index 6791c5c..737cd03 100644 --- a/.cursor/rules/ui.mdc +++ b/.cursor/rules/ui.mdc @@ -5,4 +5,5 @@ alwaysApply: true When you work with UI: 1. Use MDUI components as HTML elements -2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML. \ No newline at end of file +2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML. +3. The supporting text slot for MDUI lists is "description". \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index 50271d4..33b67cd 100644 --- a/backend/models.py +++ b/backend/models.py @@ -38,6 +38,36 @@ class Message(Base): reply_to = relationship("Message", remote_side=[id]) +class CryptoPublicKey(Base): + __tablename__ = "crypto_public_key" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True) + public_key_b64 = Column(Text, nullable=False) + + +class CryptoBackup(Base): + __tablename__ = "crypto_backup" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True) + blob_json = Column(Text, nullable=False) + + +class DMEnvelope(Base): + __tablename__ = "dm_envelope" + + id = Column(Integer, primary_key=True, index=True) + sender_id = Column(Integer, ForeignKey("user.id"), nullable=False) + recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False) + iv_b64 = Column(Text, nullable=False) + ciphertext_b64 = Column(Text, nullable=False) + salt_b64 = Column(Text, nullable=False) + iv2_b64 = Column(Text, nullable=False) + wrapped_mk_b64 = Column(Text, nullable=False) + timestamp = Column(DateTime, default=datetime.now) + + # Pydantic модели class LoginRequest(BaseModel): username: str diff --git a/backend/routes/account.py b/backend/routes/account.py index eaa8ece..5b51f54 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -2,10 +2,9 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from routes.messaging import convert_message from constants import OWNER_USERNAME from dependencies import get_current_user, get_db -from models import LoginRequest, RegisterRequest, User +from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup from utils import create_token, get_password_hash, verify_password from validation import is_valid_password, is_valid_username @@ -121,6 +120,48 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)): } +@router.get("/crypto/public-key") +def get_public_key(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first() + return {"publicKey": row.public_key_b64 if row else None} + + +@router.post("/crypto/public-key") +def set_public_key(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + pk = payload.get("publicKey") + if not pk: + raise HTTPException(status_code=400, detail="publicKey required") + row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first() + if row: + row.public_key_b64 = pk + else: + row = CryptoPublicKey(user_id=current_user.id, public_key_b64=pk) + db.add(row) + db.commit() + return {"status": "ok"} + + +@router.get("/crypto/backup") +def get_backup(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first() + return {"blob": row.blob_json if row else None} + + +@router.post("/crypto/backup") +def set_backup(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + blob = payload.get("blob") + if not blob: + raise HTTPException(status_code=400, detail="blob required") + row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first() + if row: + row.blob_json = blob + else: + row = CryptoBackup(user_id=current_user.id, blob_json=blob) + db.add(row) + db.commit() + return {"status": "ok"} + + @router.delete("/admin/user/{user_id}") def delete_user_as_owner( user_id: int, @@ -160,4 +201,20 @@ def logout( return { "status": "success", "message": "Logged out successfully" - } \ No newline at end of file + } + + +@router.get("/users") +def list_users(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + users = db.query(User).order_by(User.username.asc()).all() + return { + "users": [ + convert_user(u) for u in users if u.id != current_user.id + ] + } + + +@router.get("/crypto/public-key/of/{user_id}") +def get_public_key_of(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first() + return {"publicKey": row.public_key_b64 if row else None} \ No newline at end of file diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 36f60d8..79a3ef3 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -5,7 +5,7 @@ from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy.orm import Session from dependencies import get_current_user, get_db from constants import OWNER_USERNAME -from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User +from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User, DMEnvelope router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -68,6 +68,82 @@ async def get_messages(db: Session = Depends(get_db)): } +@router.post("/dm/send") +async def dm_send(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"] + for key in required: + if key not in payload: + raise HTTPException(status_code=400, detail=f"Missing {key}") + env = DMEnvelope( + sender_id=current_user.id, + recipient_id=int(payload["recipientId"]), + iv_b64=payload["iv"], + ciphertext_b64=payload["ciphertext"], + salt_b64=payload["salt"], + iv2_b64=payload["iv2"], + wrapped_mk_b64=payload["wrappedMk"], + ) + db.add(env) + db.commit() + db.refresh(env) + return {"status": "ok", "id": env.id} + + +@router.get("/dm/fetch") +async def dm_fetch(since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + q = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id) + if since: + q = q.filter(DMEnvelope.id > since) + envs = q.order_by(DMEnvelope.id.asc()).all() + return { + "status": "ok", + "messages": [ + { + "id": e.id, + "senderId": e.sender_id, + "recipientId": e.recipient_id, + "iv": e.iv_b64, + "ciphertext": e.ciphertext_b64, + "salt": e.salt_b64, + "iv2": e.iv2_b64, + "wrappedMk": e.wrapped_mk_b64, + "timestamp": e.timestamp.isoformat(), + } + for e in envs + ] + } + + +@router.get("/dm/history/{other_user_id}") +async def dm_history(other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + envs = ( + db.query(DMEnvelope) + .filter( + ((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id)) + | ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == current_user.id)) + ) + .order_by(DMEnvelope.id.asc()) + .all() + ) + return { + "status": "ok", + "messages": [ + { + "id": e.id, + "senderId": e.sender_id, + "recipientId": e.recipient_id, + "iv": e.iv_b64, + "ciphertext": e.ciphertext_b64, + "salt": e.salt_b64, + "iv2": e.iv2_b64, + "wrappedMk": e.wrapped_mk_b64, + "timestamp": e.timestamp.isoformat(), + } + for e in envs + ] + } + + @router.put("/edit_message/{message_id}") async def edit_message( message_id: int, @@ -147,6 +223,7 @@ async def reply_message( class MessaggingSocketManager: def __init__(self) -> None: self.connections: list[WebSocket] = [] + self.user_by_ws: dict[WebSocket, int] = {} async def send_error(self, websocket: WebSocket, type: str, e: HTTPException): await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}}) @@ -169,12 +246,19 @@ class MessaggingSocketManager: return None if type == "ping": + try: + current_user = get_current_user_inner() + if current_user: + self.user_by_ws[websocket] = current_user.id + except HTTPException: + pass await websocket.send_json({"type": "ping", "data": {"status": "success"}}) elif type == "getMessages": try: current_user = get_current_user_inner() if not current_user: raise HTTPException(401) + self.user_by_ws[websocket] = current_user.id await websocket.send_json({"type": type, "data": await get_messages(current_user, db)}) except HTTPException as e: @@ -184,6 +268,7 @@ class MessaggingSocketManager: current_user = get_current_user_inner() if not current_user: raise HTTPException(401) + self.user_by_ws[websocket] = current_user.id request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) @@ -196,6 +281,46 @@ class MessaggingSocketManager: await websocket.send_json({"type": type, "data": response}) except HTTPException as e: await self.send_error(websocket, type, e) + elif type == "dmSend": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + self.user_by_ws[websocket] = current_user.id + payload = data["data"] + required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"] + for key in required: + if key not in payload: + raise HTTPException(status_code=400, detail=f"Missing {key}") + env = DMEnvelope( + sender_id=current_user.id, + recipient_id=int(payload["recipientId"]), + iv_b64=payload["iv"], + ciphertext_b64=payload["ciphertext"], + salt_b64=payload["salt"], + iv2_b64=payload["iv2"], + wrapped_mk_b64=payload["wrappedMk"], + ) + db.add(env) + db.commit() + db.refresh(env) + await self.send_to_user(env.recipient_id, { + "type": "dmNew", + "data": { + "id": env.id, + "senderId": env.sender_id, + "recipientId": env.recipient_id, + "iv": env.iv_b64, + "ciphertext": env.ciphertext_b64, + "salt": env.salt_b64, + "iv2": env.iv2_b64, + "wrappedMk": env.wrapped_mk_b64, + "timestamp": env.timestamp.isoformat(), + } + }) + await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}) + except HTTPException as e: + await self.send_error(websocket, type, e) elif type == "editMessage": try: current_user = get_current_user_inner() @@ -264,11 +389,18 @@ class MessaggingSocketManager: logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}") finally: self.connections.remove(websocket) + if websocket in self.user_by_ws: + del self.user_by_ws[websocket] async def broadcast(self, message: dict): for websocket in self.connections: await websocket.send_json(message) + async def send_to_user(self, user_id: int, message: dict): + for websocket in self.connections: + if self.user_by_ws.get(websocket) == user_id: + await websocket.send_json(message) + messagingManager = MessaggingSocketManager() @router.websocket("/chat/ws") diff --git a/frontend/index.html b/frontend/index.html index 00e4448..e8e87be 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -147,6 +147,9 @@ Контакты + + ЛС + @@ -160,6 +163,9 @@ Скоро будет... Скоро будет... + + + @@ -411,6 +417,12 @@ +
+ + + Send Message + +
diff --git a/frontend/src/auth/api.ts b/frontend/src/auth/api.ts index 692c3b5..2855fb4 100644 --- a/frontend/src/auth/api.ts +++ b/frontend/src/auth/api.ts @@ -1,7 +1,8 @@ import { API_BASE_URL } from "../core/config"; import { showLogin } from "../navigation"; -import type { Headers, User } from "../core/types"; +import type { Headers, User, WebSocketMessage } from "../core/types"; import { clearAlerts } from "./auth"; +import { request } from "../websocket"; /** * Current authenticated user information @@ -23,6 +24,21 @@ export let authToken: string | null = null; export function setUser(token: string, user: User) { authToken = token currentUser = user + + try { + const payload: WebSocketMessage = { + type: "ping", + credentials: { + scheme: "Bearer", + credentials: authToken + }, + data: {} + } + + request(payload).then(() => { + console.log("Ping succeeded") + }) + } catch {} } /** diff --git a/frontend/src/auth/auth.ts b/frontend/src/auth/auth.ts index 2aa83ba..5d0da0b 100644 --- a/frontend/src/auth/auth.ts +++ b/frontend/src/auth/auth.ts @@ -8,8 +8,9 @@ import { initializeProfile } from "../userPanel/profile/profile"; import type { ErrorResponse, LoginResponse, LoginRequest, RegisterRequest } from "../core/types"; import { API_BASE_URL } from "../core/config"; -import { loadChat, showLogin, showRegister } from "../navigation"; +import { showChat, showLogin, showRegister } from "../navigation"; import { setUser } from "./api"; +import { ensureKeysOnLogin } from "./crypto"; import { id } from "../utils/utils"; /** @@ -71,7 +72,12 @@ async function handleLogin(e: Event): Promise { const data: LoginResponse = await response.json(); // Store the JWT token setUser(data.token, data.user) - loadChat(); + try { + await ensureKeysOnLogin(password); + } catch (e) { + console.error("Key setup failed:", e); + } + showChat(); initializeProfile(); // Initialize profile after login } else { const data: ErrorResponse = await response.json(); diff --git a/frontend/src/auth/crypto.ts b/frontend/src/auth/crypto.ts new file mode 100644 index 0000000..902cf7b --- /dev/null +++ b/frontend/src/auth/crypto.ts @@ -0,0 +1,98 @@ +import { API_BASE_URL } from "../core/config"; +import { getAuthHeaders } from "./api"; +import { generateX25519KeyPair } from "../crypto/asymmetric"; +import { encryptBackupWithPassword, decryptBackupWithPassword, encodeBlob, decodeBlob } from "../crypto/backup"; +import { b64, ub64 } from "../utils/utils"; +import type { BackupBlob, UploadPublicKeyRequest } from "../core/types"; + +let currentPublicKey: Uint8Array | null = null; +let currentPrivateKey: Uint8Array | null = null; + +async function fetchPublicKey(): Promise { + const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers: getAuthHeaders(true) }); + if (!res.ok) return null; + const data = await res.json(); + if (!data?.publicKey) return null; + return ub64(data.publicKey); +} + +async function uploadPublicKey(publicKey: Uint8Array): Promise { + const payload: UploadPublicKeyRequest = { + publicKey: b64(publicKey) + } + + await fetch(`${API_BASE_URL}/crypto/public-key`, { + method: "POST", + headers: getAuthHeaders(true), + body: JSON.stringify(payload) + }); +} + +async function fetchBackupBlob(): Promise { + const res = await fetch(`${API_BASE_URL}/crypto/backup`, { + method: "GET", + headers: getAuthHeaders(true) + }); + if (res.ok) { + const response: BackupBlob = await res.json(); + return response.blob; + } else { + return null; + } +} + +async function uploadBackupBlob(blobJson: string): Promise { + const payload: BackupBlob = { blob: blobJson } + + await fetch(`${API_BASE_URL}/crypto/backup`, { + method: "POST", + headers: getAuthHeaders(true), + body: JSON.stringify(payload) + }); +} + +export interface UserKeyPairMemory { + publicKey: Uint8Array; + privateKey: Uint8Array; +} + +export function getCurrentKeys(): UserKeyPairMemory | null { + if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey }; + return null; +} + +export async function ensureKeysOnLogin(password: string): Promise { + // Try to restore from backup + const blobJson = await fetchBackupBlob(); + if (blobJson) { + const blob = decodeBlob(blobJson); + const bundle = await decryptBackupWithPassword(password, blob); + currentPrivateKey = bundle.privateKey; + // Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous + // In our simple scheme, we rely on server having the public key or we reupload generated one on first setup + const serverPub = await fetchPublicKey(); + if (serverPub) { + currentPublicKey = serverPub; + } else { + // We don't have the corresponding public key from server; regenerate pair to resync + const pair = generateX25519KeyPair(); + currentPublicKey = pair.publicKey; + currentPrivateKey = pair.privateKey; + await uploadPublicKey(currentPublicKey); + const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey }); + await uploadBackupBlob(encodeBlob(newBlob)); + } + return { publicKey: currentPublicKey!, privateKey: currentPrivateKey! }; + } + + // First-time setup: generate keys and upload + const pair = generateX25519KeyPair(); + currentPublicKey = pair.publicKey; + currentPrivateKey = pair.privateKey; + await uploadPublicKey(currentPublicKey); + const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey }); + await uploadBackupBlob(encodeBlob(encBlob)); + return { publicKey: currentPublicKey, privateKey: currentPrivateKey }; +} + + diff --git a/frontend/src/chat/chat.ts b/frontend/src/chat/chat.ts index 120328d..37c4951 100644 --- a/frontend/src/chat/chat.ts +++ b/frontend/src/chat/chat.ts @@ -6,13 +6,14 @@ */ import { API_BASE_URL } from "../core/config"; -import { websocket } from "../websocket"; +import { request } from "../websocket"; import type { Message, Messages, WebSocketMessage } from "../core/types"; import { formatTime } from "../utils/utils"; import { show as showContextMenu } from "./contextMenu"; import { show as showUserProfileDialog } from "./profileDialog"; import defaultAvatar from "../resources/images/default-avatar.png"; import { authToken, currentUser, getAuthHeaders } from "../auth/api"; +import { ChatPanelController, PublicChatPanel } from "./panel"; /** * Adds a new message to the chat interface @@ -122,43 +123,15 @@ export function addMessage(message: Message, isAuthor: boolean): void { messagesContainer.scrollTop = messagesContainer.scrollHeight; } -/** - * Loads chat messages from the server - */ -export function loadMessages(): void { - fetch(`${API_BASE_URL}/get_messages`, { - headers: getAuthHeaders() - }) - .then(response => response.json()) - .then((data: Messages) => { - if (data.messages && data.messages.length > 0) { - const messagesContainer = document.querySelector('.chat-messages') as HTMLElement; - - const lastMessage = messagesContainer.lastElementChild as HTMLElement - let lastMessageId: number = 0 - if (lastMessage) { - lastMessageId = Number(lastMessage.dataset.id) - } - - // Добавляем только новые сообщения - data.messages.forEach(msg => { - if (msg.id > lastMessageId) { - addMessage(msg, msg.username == currentUser!.username); - } - }); - } - }); -} - /** * Sends a message via WebSocket */ -export function sendMessage(): void { +export async function sendMessage(): Promise { const input = document.querySelector('.message-input') as HTMLInputElement; const message = input.value.trim(); if (message) { - const payload: WebSocketMessage = { + const response = await request({ data: { content: message }, @@ -167,29 +140,15 @@ export function sendMessage(): void { credentials: authToken! }, type: "sendMessage" - } + }) - let callback: ((e: MessageEvent) => void) | null = null - callback = (e) => { - websocket.removeEventListener("message", callback!); - const response: WebSocketMessage = JSON.parse(e.data) - console.log(response) - if (!response.error) { - input.value = ""; - } + console.log(response) + if (!response.error) { + input.value = ""; } - websocket.addEventListener("message", callback); - - websocket.send(JSON.stringify(payload)); } } - -document.getElementById('message-form')!.addEventListener('submit', (e) => { - e.preventDefault(); - sendMessage(); -}); - /** * Updates an existing message in the chat interface * @param {Message} message - Updated message object @@ -230,22 +189,28 @@ export function removeMessage(messageId: number): void { * @param {WebSocketMessage} response - WebSocket response */ export function handleWebSocketMessage(response: WebSocketMessage): void { - switch (response.type) { - case 'messageEdited': - if (response.data) { - updateMessage(response.data); - } - break; - case 'messageDeleted': - if (response.data && response.data.message_id) { - removeMessage(response.data.message_id); - } - break; - case 'newMessage': - if (response.data) { - const isAuthor = response.data.username === currentUser?.username; - addMessage(response.data, isAuthor); - } - break; + if (ChatPanelController.active == publicChatPanel) { + switch (response.type) { + case 'messageEdited': + if (response.data) { + updateMessage(response.data); + } + break; + case 'messageDeleted': + if (response.data && response.data.message_id) { + removeMessage(response.data.message_id); + } + break; + case 'newMessage': + if (response.data) { + const isAuthor = response.data.username === currentUser?.username; + addMessage(response.data, isAuthor); + } + break; + } } -} \ No newline at end of file +} + +export const publicChatPanel = new PublicChatPanel(); + +publicChatPanel.activate(); \ No newline at end of file diff --git a/frontend/src/chat/contextMenu.ts b/frontend/src/chat/contextMenu.ts index 92f2056..7c960b1 100644 --- a/frontend/src/chat/contextMenu.ts +++ b/frontend/src/chat/contextMenu.ts @@ -5,8 +5,8 @@ * @version 1.0.0 */ -import { websocket } from "../websocket"; -import type { Message, WebSocketMessage } from "../core/types"; +import { request } from "../websocket"; +import type { Message } from "../core/types"; import { showSuccess, showError } from "../utils/notification"; import { delay, id } from "../utils/utils"; import type { Dialog } from "mdui/components/dialog"; @@ -84,9 +84,16 @@ export function show(message: Message, x: number, y: number): void { const isAuthor = message.username === currentUser?.username; const isOwner = currentUser?.admin; - + + // Check if we're in a DM conversation + const isInDm = document.querySelector('.chat-tabs mdui-tab[value="dms"]')?.getAttribute('active') === 'true'; + + // Show edit only for own messages editItem.style.display = isAuthor ? 'flex' : 'none'; - deleteItem.style.display = (isAuthor || isOwner) ? 'flex' : 'none'; + + // Show delete for own messages, admin on any message, or in DMs for any message + const canDelete = isAuthor || isOwner || isInDm; + deleteItem.style.display = canDelete ? 'flex' : 'none'; // Position the menu properly menu.style.display = 'block'; @@ -184,7 +191,7 @@ function hideEditDialog(): void { * Saves the edited message * @private */ -function saveEdit(): void { +async function saveEdit(): Promise { if (!currentMessage) return; const textField = editDialog.querySelector('#edit-message-input') as TextField; @@ -195,7 +202,7 @@ function saveEdit(): void { return; } - const payload: WebSocketMessage = { + const response = await request({ type: "editMessage", data: { message_id: currentMessage.id, @@ -205,22 +212,14 @@ function saveEdit(): void { scheme: "Bearer", credentials: authToken! } - }; + }); - let callback: ((e: MessageEvent) => void) | null = null; - callback = (e) => { - websocket.removeEventListener("message", callback!); - const response: WebSocketMessage = JSON.parse(e.data); - - if (response.error) { - showError(response.error.detail); - } else { - showSuccess('Message edited successfully'); - hideEditDialog(); - } - }; - websocket.addEventListener("message", callback); - websocket.send(JSON.stringify(payload)); + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Message edited successfully'); + hideEditDialog(); + } } /** @@ -257,7 +256,7 @@ function hideReplyDialog(): void { * Sends the reply message * @private */ -function sendReply(): void { +async function sendReply(): Promise { if (!currentMessage) return; const textField = replyDialog.querySelector('#reply-message-input') as TextField; @@ -268,7 +267,7 @@ function sendReply(): void { return; } - const payload: WebSocketMessage = { + const response = await request({ type: "replyMessage", data: { content: content, @@ -278,25 +277,17 @@ function sendReply(): void { scheme: "Bearer", credentials: authToken! } - }; + }); - let callback: ((e: MessageEvent) => void) | null = null; - callback = (e) => { - websocket.removeEventListener("message", callback!); - const response: WebSocketMessage = JSON.parse(e.data); - - if (response.error) { - showError(response.error.detail); - } else { - showSuccess('Reply sent successfully'); - hideReplyDialog(); - if (textField) { - textField.value = ''; - } + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Reply sent successfully'); + hideReplyDialog(); + if (textField) { + textField.value = ''; } - }; - websocket.addEventListener("message", callback); - websocket.send(JSON.stringify(payload)); + } } /** @@ -304,12 +295,12 @@ function sendReply(): void { * @param {Message} message - The message to delete * @private */ -function deleteMessage(message: Message): void { +async function deleteMessage(message: Message): Promise { if (!confirm('Are you sure you want to delete this message?')) { return; } - const payload: WebSocketMessage = { + const response = await request({ type: "deleteMessage", data: { message_id: message.id @@ -318,21 +309,13 @@ function deleteMessage(message: Message): void { scheme: "Bearer", credentials: authToken! } - }; + }); - let callback: ((e: MessageEvent) => void) | null = null; - callback = (e) => { - websocket.removeEventListener("message", callback!); - const response: WebSocketMessage = JSON.parse(e.data); - - if (response.error) { - showError(response.error.detail); - } else { - showSuccess('Message deleted successfully'); - } - }; - websocket.addEventListener("message", callback); - websocket.send(JSON.stringify(payload)); + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Message deleted successfully'); + } } init(); \ No newline at end of file diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts new file mode 100644 index 0000000..13450c3 --- /dev/null +++ b/frontend/src/chat/dm.ts @@ -0,0 +1,392 @@ +import { API_BASE_URL } from "../core/config"; +import { authToken, getAuthHeaders, currentUser } from "../auth/api"; +import { DmPanel } from "./panel"; +import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric"; +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric"; +import { randomBytes } from "../crypto/kdf"; +import { getCurrentKeys } from "../auth/crypto"; +import { request, websocket } from "../websocket"; +import type { FetchDMResponse, SendDMRequest, WebSocketMessage, User, DmEnvelope } from "../core/types"; +import type { Tabs } from "mdui/components/tabs"; +import { b64, ub64 } from "../utils/utils"; + +export async function sendDm(recipientId: number, recipientPublicKeyB64: string, plaintext: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext)); + const wrap = await aesGcmEncrypt(wk, mk); + + await fetch(`${API_BASE_URL}/dm/send`, { + method: "POST", + headers: getAuthHeaders(true), + body: JSON.stringify({ + recipientId: recipientId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + }) + }); +} + + + +export async function fetchDm(since?: number): Promise { + const url = new URL(`${API_BASE_URL}/dm/fetch`); + if (since) url.searchParams.set("since", String(since)); + + const response = await fetch(url, { + headers: getAuthHeaders(true) + }); + + if (response.ok) { + const data: FetchDMResponse = await response.json(); + return data.messages ?? []; + } else { + return []; + } +} + +export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // Obtain the key + const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk)); + + // Decrypt + const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext)); + return new TextDecoder().decode(msg); +} + +let activeDm: { userId: number; username: string; publicKey: string | null } | null = null; +let usersLoaded = false; +let dmPanel: DmPanel | null = null; +const dmBadgeByUserId: Map = new Map(); +const dmSupportingTextByUserId: Map = new Map(); + +function getLastReadId(userId: number): number { + try { + const v = localStorage.getItem(`dmLastRead:${userId}`); + return v ? Number(v) : 0; + } catch { + return 0; + } +} + +function setLastReadId(userId: number, id: number): void { + try { + localStorage.setItem(`dmLastRead:${userId}`, String(id)); + } catch {} +} + +async function loadUsers() { + const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(true) }); + if (!res.ok) return; + const data = await res.json(); + const list = document.getElementById("dm-users")!; + list.innerHTML = ""; + (data.users || []).forEach((u: User) => { + const item = document.createElement("mdui-list-item"); + item.id = `dm-user-${u.id}`; + + // Add avatar + const avatar = document.createElement("img"); + avatar.src = u.profile_picture || "./src/resources/images/default-avatar.png"; + avatar.alt = u.username; + avatar.slot = "icon"; + avatar.style.width = "40px"; + avatar.style.height = "40px"; + avatar.style.borderRadius = "50%"; + avatar.style.objectFit = "cover"; + + // Handle avatar load error + avatar.addEventListener("error", () => { + avatar.src = "./src/resources/images/default-avatar.png"; + }); + + item.appendChild(avatar); + + // Set headline (username) + item.setAttribute("headline", u.username); + + // Add supporting text container (hidden until loaded) + const lastMessageEl = document.createElement("div"); + lastMessageEl.slot = "description"; + lastMessageEl.style.fontSize = "12px"; + lastMessageEl.style.color = "var(--mdui-color-on-surface-variant)"; + lastMessageEl.style.whiteSpace = "pre-line"; + lastMessageEl.style.display = "none"; + item.appendChild(lastMessageEl); + dmSupportingTextByUserId.set(u.id, lastMessageEl); + + // Add unread badge (hidden by default) + const badge = document.createElement("mdui-badge"); + badge.setAttribute("slot", "end-icon"); + badge.style.display = "none"; + item.appendChild(badge); + dmBadgeByUserId.set(u.id, badge); + + // Load last message when element becomes visible + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + loadLastMessage(u.id); + observer.unobserve(entry.target); + } + }); + }); + observer.observe(item); + + item.addEventListener("click", async () => { + activeDm = { userId: u.id, username: u.username, publicKey: null }; + if (!dmPanel) { + dmPanel = new DmPanel( + async (text: string) => { + if (activeDm?.publicKey) { + // WebSocket realtime send + const keys = getCurrentKeys(); + if (!keys) return; + + // Encryption key + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = ecdhSharedSecret(keys.privateKey, ub64(activeDm.publicKey)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + // Encrypt the message + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(text)); + const wrap = await aesGcmEncrypt(wk, mk); + + const payload: SendDMRequest = { + recipientId: activeDm.userId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + } + + request({ + type: "dmSend", + credentials: { + scheme: "Bearer", + credentials: authToken! + }, + data: payload + }); + } + }, + async () => { + // Load DM history for the active conversation + if (!activeDm?.publicKey || !dmPanel) return; + const response = await fetch(`${API_BASE_URL}/dm/history/${activeDm.userId}`, { + headers: getAuthHeaders(true) + }); + if (response.ok) { + const data = await response.json(); + const messages: DmEnvelope[] = data.messages || []; + const container = document.getElementById("chat-messages")!; + container.innerHTML = ""; + let maxIncomingId = 0; + for (const env of messages) { + try { + // Always use other user's public key for ECDH (our private + their public) + const text = await decryptDm(env, activeDm.publicKey!); + const isAuthor = env.senderId !== activeDm.userId; + const username = isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"); + dmPanel.appendMessageWithId({ + id: env.id, + content: text, + username: username, + timestamp: env.timestamp, + is_read: false, + is_edited: false + }); + if (env.senderId === activeDm.userId && env.id > maxIncomingId) { + maxIncomingId = env.id; + } + } catch (e) { + console.error("Error while loading message:", e); + } + } + container.scrollTop = container.scrollHeight; + if (maxIncomingId > 0) { + setLastReadId(activeDm.userId, maxIncomingId); + const badgeEl = dmBadgeByUserId.get(activeDm.userId); + if (badgeEl) { + badgeEl.style.display = "none"; + badgeEl.textContent = ""; + } + } + } + } + ); + } + dmPanel.setOtherUser(u.username); + dmPanel.setTitle(u.username); + dmPanel.clearMessages(); + + // Add profile click functionality to chat header + const chatHeaderAvatar = document.querySelector('.chat-header-avatar') as HTMLElement; + if (chatHeaderAvatar) { + chatHeaderAvatar.style.cursor = 'pointer'; + chatHeaderAvatar.onclick = () => dmPanel?.onProfileClicked(); + } + + const resPk = await fetch(`${API_BASE_URL}/crypto/public-key/of/${u.id}`, { headers: getAuthHeaders(true) }); + if (resPk.ok) { + const pkData = await resPk.json(); + activeDm!.publicKey = pkData.publicKey; + } + + // Only activate after we have the public key so loader can decrypt + dmPanel.activate(); + // Clear unread badge on open + const badgeEl = dmBadgeByUserId.get(u.id); + if (badgeEl) { + badgeEl.textContent = ""; + badgeEl.style.display = "none"; + } + }); + list.appendChild(item); + }); +} + +async function loadLastMessage(userId: number): Promise { + try { + const pkRes = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(true) }); + if (!pkRes.ok) return; + const pkData = await pkRes.json(); + const otherPk = pkData.publicKey as string; + const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=50`, { + headers: getAuthHeaders(true) + }); + if (response.ok) { + const data = await response.json(); + const messages: DmEnvelope[] = data.messages || []; + const supporting = dmSupportingTextByUserId.get(userId); + const badgeEl = dmBadgeByUserId.get(userId); + if (!supporting || !badgeEl) return; + let lastPlaintext: string | null = null; + let lastEnv: DmEnvelope | null = null; + for (const env of messages) { + if (!lastEnv || env.id > lastEnv.id) lastEnv = env; + } + if (lastEnv) { + try { lastPlaintext = await decryptDm(lastEnv, otherPk); } catch {} + } + if (lastPlaintext && lastPlaintext.trim().length > 0) { + const lines = lastPlaintext.split(/\r?\n/).slice(0, 2); + supporting.textContent = lines.join("\n"); + supporting.style.display = "block"; + } else { + supporting.textContent = ""; + supporting.style.display = "none"; + } + const lastRead = getLastReadId(userId); + let unread = 0; + for (const env of messages) { + if (env.senderId === userId && env.id > lastRead) unread++; + } + if (unread > 0) { + badgeEl.textContent = String(unread); + badgeEl.style.display = "inline-flex"; + } else { + badgeEl.textContent = ""; + badgeEl.style.display = "none"; + } + } else { + const supporting = dmSupportingTextByUserId.get(userId); + if (supporting) { + supporting.textContent = ""; + supporting.style.display = "none"; + } + } + } catch (error) { + const supporting = dmSupportingTextByUserId.get(userId); + if (supporting) { + supporting.textContent = ""; + supporting.style.display = "none"; + } + } +} + +function init() { + const tabs = document.querySelector(".chat-tabs mdui-tabs") as Tabs; + const dmTab = tabs?.querySelector('mdui-tab[value="dms"]')!; + function ensureUsersLoaded() { + if (!usersLoaded) { + usersLoaded = true; + loadUsers(); + } + } + dmTab.addEventListener("click", ensureUsersLoaded); + tabs.addEventListener("change", (e: any) => { + if (e.detail?.value === "dms") { + ensureUsersLoaded(); + dmPanel?.activate(); + } + }); +} + +init(); + +// realtime incoming DMs +websocket.addEventListener("message", async (e) => { + try { + const msg: WebSocketMessage = JSON.parse((e as MessageEvent).data); + if (msg.type === "dmNew") { + if (activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) { + // Always use other user's public key (our private is implied by getCurrentKeys) + const plaintext = await decryptDm(msg.data, activeDm.publicKey!); + if (dmPanel) { + const isAuthor = msg.data.senderId !== activeDm.userId; + dmPanel.appendMessageWithId({ + id: msg.data.id, + content: plaintext, + username: isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"), + timestamp: msg.data.timestamp, + is_read: false, + is_edited: false + }); + } + if (msg.data.senderId === activeDm.userId) { + setLastReadId(activeDm.userId, Math.max(getLastReadId(activeDm.userId), msg.data.id)); + } + } else { + const otherUserId = msg.data.senderId; + const badgeEl = dmBadgeByUserId.get(otherUserId); + if (badgeEl) { + const current = Number(badgeEl.textContent || 0); + const next = (current || 0) + 1; + badgeEl.textContent = String(next); + badgeEl.style.display = "inline-flex"; + } + try { + const pkRes = await fetch(`${API_BASE_URL}/crypto/public-key/of/${otherUserId}`, { headers: getAuthHeaders(true) }); + if (pkRes.ok) { + const pkData = await pkRes.json(); + const plaintext = await decryptDm(msg.data, pkData.publicKey); + const supporting = dmSupportingTextByUserId.get(otherUserId); + if (supporting && plaintext) { + const lines = plaintext.split(/\r?\n/).slice(0, 2); + supporting.textContent = lines.join("\n"); + supporting.style.display = lines.length ? "block" : "none"; + } + } + } catch {} + } + } + } catch {} +}); \ No newline at end of file diff --git a/frontend/src/chat/panel.ts b/frontend/src/chat/panel.ts new file mode 100644 index 0000000..7f888c3 --- /dev/null +++ b/frontend/src/chat/panel.ts @@ -0,0 +1,175 @@ +import { authToken, currentUser, getAuthHeaders } from "../auth/api"; +import { API_BASE_URL } from "../core/config"; +import type { Message, Messages, WebSocketMessage } from "../core/types"; +import { request } from "../websocket"; +import { addMessage } from "./chat"; +import { show as showContextMenu } from "./contextMenu"; +import { show as showProfileDialog } from "./profileDialog"; + +const titleEl = document.getElementById("chat-name")!; +const messages = document.getElementById("chat-messages")!; +const input = document.getElementById("message-input") as HTMLInputElement; +const form = document.getElementById("message-form") as HTMLFormElement; + +export abstract class ChatPanelController { + static active: ChatPanelController | null = null; + static mounted = false; + + activate(): void { + ChatPanelController.active = this; + if (currentUser) { + this.loadMessages(); + } + } + + setTitle(title: string): void { + titleEl.textContent = title; + } + + clearMessages(): void { + messages.innerHTML = ""; + } + + appendSimple(text: string, isAuthor: boolean): void { + const div = document.createElement("div"); + div.className = `message ${isAuthor ? "sent" : "received"}`; + const inner = document.createElement("div"); + inner.className = "message-inner"; + const content = document.createElement("div"); + content.className = "message-content"; + content.textContent = text; + inner.appendChild(content); + div.appendChild(inner); + messages.appendChild(div); + messages.scrollTop = messages.scrollHeight; + } + + protected abstract onSubmit(text: string): void | Promise; + protected abstract loadMessages(): void | Promise; + public abstract onProfileClicked(): void; + + static mountOnce(): void { + if (this.mounted) return; + this.mounted = true; + if (!form) return; + form.addEventListener( + "submit", + (e) => { + if (!ChatPanelController.active) return; // let others handle + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + const text = input.value.trim(); + if (!text) return; + Promise.resolve(ChatPanelController.active.onSubmit(text)).finally(() => { + input.value = ""; + }); + }, + true + ); + } +} + +export class PublicChatPanel extends ChatPanelController { + protected async onSubmit(text: string): Promise { + const payload: WebSocketMessage = { + data: { content: text }, + credentials: { scheme: "Bearer", credentials: authToken! }, + type: "sendMessage" + }; + await request(payload); + } + + protected loadMessages(): void { + fetch(`${API_BASE_URL}/get_messages`, { + headers: getAuthHeaders() + }) + .then(response => response.json()) + .then((data: Messages) => { + if (data.messages && data.messages.length > 0) { + messages.innerHTML = ""; + + const messagesContainer = document.querySelector('.chat-messages') as HTMLElement; + + const lastMessage = messagesContainer.lastElementChild as HTMLElement + let lastMessageId: number = 0 + if (lastMessage) { + lastMessageId = Number(lastMessage.dataset.id) + } + + // Добавляем только новые сообщения + data.messages.forEach(msg => { + console.log(msg); + if (msg.id > lastMessageId) { + addMessage(msg, msg.username == currentUser!.username); + } + }); + } + }); + } + + public onProfileClicked(): void { + // Public chat doesn't have a specific profile to show + } +} + +export class DmPanel extends ChatPanelController { + private sender: (text: string) => Promise; + private loader: () => Promise | void; + private otherUsername: string | null = null; + + constructor(sender: (text: string) => Promise, loader: () => Promise | void) { + super(); + this.sender = sender; + this.loader = loader; + } + + setOtherUser(username: string): void { + this.otherUsername = username; + } + + protected async onSubmit(text: string): Promise { + this.appendSimple(text, true); + await this.sender(text); + } + + protected loadMessages(): void | Promise { + return this.loader(); + } + + public onProfileClicked(): void { + if (this.otherUsername) { + // Import and show the profile dialog + showProfileDialog(this.otherUsername!); + } + } + + appendMessageWithId(message: Message): void { + const div = document.createElement("div"); + const isAuthor = message.username === currentUser?.username; + div.className = `message ${isAuthor ? "sent" : "received"}`; + div.setAttribute("data-message-id", message.id.toString()); + div.setAttribute("data-timestamp", message.timestamp); + + const inner = document.createElement("div"); + inner.className = "message-inner"; + + const content = document.createElement("div"); + content.className = "message-content"; + content.textContent = message.content; + + inner.appendChild(content); + div.appendChild(inner); + + // Add context menu support + div.addEventListener("contextmenu", (e) => { + e.preventDefault(); + showContextMenu(message, e.clientX, e.clientY); + }); + + messages.appendChild(div); + messages.scrollTop = messages.scrollHeight; + } +} + +ChatPanelController.mountOnce(); \ No newline at end of file diff --git a/frontend/src/chat/profileDialog.ts b/frontend/src/chat/profileDialog.ts index 3018ee8..3b8906e 100644 --- a/frontend/src/chat/profileDialog.ts +++ b/frontend/src/chat/profileDialog.ts @@ -9,8 +9,9 @@ import { getAuthHeaders, currentUser } from "../auth/api"; import { API_BASE_URL } from "../core/config"; import type { UserProfile } from "../core/types"; import { showError, showSuccess } from "../utils/notification"; -import { formatTime, id } from "../utils/utils"; +import { delay, formatTime, id } from "../utils/utils"; import defaultAvatar from "../resources/images/default-avatar.png"; +import type { Tabs } from "mdui/components/tabs"; import type { Dialog } from "mdui/components/dialog"; import type { TextField } from "mdui/components/text-field"; @@ -36,6 +37,41 @@ function bindEvents(): void { editBioBtn?.addEventListener('click', () => startEditBio()); saveBioBtn?.addEventListener('click', () => saveBio()); cancelBioBtn?.addEventListener('click', () => cancelEditBio()); + + // DM button event + const dmButton = dialog?.querySelector('#dm-button'); + dmButton?.addEventListener('click', () => startDm()); +} + +/** + * Starts a direct message conversation + * @private + */ +async function startDm(): Promise { + if (!currentProfile || isOwnProfile) return; + + // Hide the profile dialog + hide(); + + // Switch to DMs tab + const tabs = document.querySelector('.chat-tabs mdui-tabs') as Tabs; + if (tabs) { + tabs.value = 'dms'; + } + + // Find and click on the user in the DM users list + await delay(100); + const dmUsersList = document.getElementById("dm-users"); + if (dmUsersList) { + const userItems = dmUsersList.querySelectorAll('mdui-list-item'); + for (const item of userItems) { + const headline = item.getAttribute('headline'); + if (headline === currentProfile?.username) { + (item as HTMLElement).click(); + break; + } + } + } } /** diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 4447a1d..b4cb17a 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -5,6 +5,7 @@ * @version 1.0.0 */ + /** * HTTP headers object type * @typedef {Object.} Headers @@ -83,6 +84,7 @@ export interface User { username: string; admin?: boolean; bio?: string; + profile_picture: string; } /** @@ -136,6 +138,19 @@ export interface RegisterRequest { confirm_password: string; } +export interface UploadPublicKeyRequest { + publicKey: string; +} + +export interface SendDMRequest { + recipientId: number; + iv: string; + ciphertext: string; + salt: string; + iv2: string; + wrappedMk: string; +} + // Responses /** @@ -149,6 +164,26 @@ export interface LoginResponse { token: string; } +export interface BackupBlob { + blob: string; +} + +export interface DmEnvelope { + id: number; + senderId: number; + recipientId: number; + iv: string; + ciphertext: string; + salt: string; + iv2: string; + wrappedMk: string; + timestamp: string; +} + +export interface FetchDMResponse { + messages: DmEnvelope[] +} + // --------------- // WebSocket types // --------------- diff --git a/frontend/src/crypto/asymmetric.ts b/frontend/src/crypto/asymmetric.ts new file mode 100644 index 0000000..a086efa --- /dev/null +++ b/frontend/src/crypto/asymmetric.ts @@ -0,0 +1,21 @@ +import nacl from "tweetnacl"; +import { hkdfExtractAndExpand } from "../crypto/kdf"; + +export interface X25519KeyPair { + publicKey: Uint8Array; + privateKey: Uint8Array; +} + +export function generateX25519KeyPair(): X25519KeyPair { + const kp = nacl.box.keyPair(); + return { publicKey: kp.publicKey, privateKey: kp.secretKey }; +} + +export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array { + // nacl.box.before returns shared key (Curve25519, XSalsa20-Poly1305 context). We use it as IKM into HKDF. + return nacl.box.before(theirPublicKey, myPrivateKey); +} + +export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise { + return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32); +} \ No newline at end of file diff --git a/frontend/src/crypto/backup.ts b/frontend/src/crypto/backup.ts new file mode 100644 index 0000000..da5d320 --- /dev/null +++ b/frontend/src/crypto/backup.ts @@ -0,0 +1,68 @@ +import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric"; +import { importPassword, deriveKEK, randomBytes } from "./kdf"; + +export interface PrivateKeyBundle { + version: 1; + privateKey: Uint8Array; // X25519 private key +} + +export interface EncryptedBackupBlob { + salt: Uint8Array; // for PBKDF2 derivation of KEK + iv: Uint8Array; // AES-GCM IV + ciphertext: Uint8Array; // encrypted serialized PrivateKeyBundle +} + +export function serializeBundle(bundle: PrivateKeyBundle): Uint8Array { + const header = new Uint8Array([bundle.version]); + const len = new Uint8Array(new Uint32Array([bundle.privateKey.length]).buffer); + const out = new Uint8Array(1 + 4 + bundle.privateKey.length); + out.set(header, 0); + out.set(len, 1); + out.set(bundle.privateKey, 5); + return out; +} + +export function deserializeBundle(data: Uint8Array): PrivateKeyBundle { + const version = data[0] as 1; + const len = new Uint32Array(data.slice(1, 5).buffer)[0]; + const pk = data.slice(5, 5 + len); + return { version, privateKey: pk }; +} + +export async function encryptBackupWithPassword(password: string, bundle: PrivateKeyBundle): Promise { + const salt = randomBytes(16); + const pw = await importPassword(password); + const kek = await deriveKEK(pw, salt); + const serialized = serializeBundle(bundle); + const { iv, ciphertext } = await aesGcmEncrypt(kek, serialized); + return { salt, iv, ciphertext }; +} + +export async function decryptBackupWithPassword(password: string, blob: EncryptedBackupBlob): Promise { + const pw = await importPassword(password); + const kek = await deriveKEK(pw, blob.salt); + const plaintext = await aesGcmDecrypt(kek, blob.iv, blob.ciphertext); + return deserializeBundle(plaintext); +} + +export function encodeBlob(blob: EncryptedBackupBlob): string { + function b64(a: Uint8Array) { return btoa(String.fromCharCode(...a)); } + return JSON.stringify({ + salt: b64(blob.salt), + iv: b64(blob.iv), + ciphertext: b64(blob.ciphertext) + }); +} + +export function decodeBlob(json: string): EncryptedBackupBlob { + function ub64(s: string) { + const bin = atob(s); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr; + } + const obj = JSON.parse(json); + return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) }; +} + + diff --git a/frontend/src/crypto/kdf.ts b/frontend/src/crypto/kdf.ts new file mode 100644 index 0000000..133d90c --- /dev/null +++ b/frontend/src/crypto/kdf.ts @@ -0,0 +1,31 @@ +export async function importPassword(password: string): Promise { + const enc = new TextEncoder(); + return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]); +} + +export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000): Promise { + const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt; + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" }, + passwordKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} + +export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise { + const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial; + const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt; + const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info; + + const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8); + return new Uint8Array(bits); +} + +export function randomBytes(length: number): Uint8Array { + const out = new Uint8Array(length); + crypto.getRandomValues(out); + return out; +} \ No newline at end of file diff --git a/frontend/src/crypto/symmetric.ts b/frontend/src/crypto/symmetric.ts new file mode 100644 index 0000000..45b85d0 --- /dev/null +++ b/frontend/src/crypto/symmetric.ts @@ -0,0 +1,31 @@ +export interface AesGcmCiphertext { + iv: Uint8Array; + ciphertext: Uint8Array; +} + +export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | ArrayBuffer): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plaintextBuffer = plaintext instanceof Uint8Array ? plaintext.buffer as ArrayBuffer : plaintext; + const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintextBuffer); + return { iv, ciphertext: new Uint8Array(ct) }; +} + +export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise { + // Normalize IV to ArrayBuffer (12 bytes for AES-GCM) + const ivBuf: ArrayBuffer = iv instanceof Uint8Array + ? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength) + : (iv as ArrayBuffer); + + // Normalize ciphertext to a contiguous ArrayBuffer slice + const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array + ? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength) + : (ciphertext as ArrayBuffer); + + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBuf }, key, ctBuf); + return new Uint8Array(pt as ArrayBuffer); +} + +export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise { + const keyBuffer = rawKey instanceof Uint8Array ? rawKey.buffer as ArrayBuffer : rawKey; + return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); +} \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b49445f..b3c1090 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -16,4 +16,6 @@ import "./core/init"; import "./userPanel/profile/profile"; import "./chat/contextMenu"; import "./chat/profileDialog"; -import "./electron/electron"; \ No newline at end of file +import "./electron/electron"; +import "./chat/panel"; +import "./chat/dm"; \ No newline at end of file diff --git a/frontend/src/navigation.ts b/frontend/src/navigation.ts index f72e4b1..81081dc 100644 --- a/frontend/src/navigation.ts +++ b/frontend/src/navigation.ts @@ -1,5 +1,5 @@ import { clearAlerts } from "./auth/auth"; -import { loadMessages } from "./chat/chat"; +import { publicChatPanel } from "./chat/chat"; import { id } from "./utils/utils"; const loginForm = id("login-form"); @@ -36,14 +36,7 @@ export function showChat(): void { loginForm.style.display = 'none'; registerForm.style.display = 'none'; chatInterface.style.display = 'block'; - loadMessages(); titleBar.classList.remove("color-surface"); -} -/** - * Loads the chat interface and initializes messaging. - */ -export function loadChat(): void { - showChat(); - loadMessages(); + publicChatPanel.activate(); } \ No newline at end of file diff --git a/frontend/src/userPanel/userpanel.ts b/frontend/src/userPanel/userpanel.ts index 8ecec8a..1ec52bd 100644 --- a/frontend/src/userPanel/userpanel.ts +++ b/frontend/src/userPanel/userpanel.ts @@ -8,6 +8,7 @@ import { Dialog } from "mdui/components/dialog"; import { loadProfilePicture } from "./profile/upload"; import { id } from "../utils/utils"; +import { publicChatPanel } from "../chat/chat"; // сварачивание и разворачивание чата const chatCollapseBtn = id('hide-chat')!; @@ -74,12 +75,14 @@ function setupChatSwitching(): void { chat1.addEventListener('click', () => { animateChatSwitch(() => { chatName.textContent = 'Общий чат'; + publicChatPanel.activate(); }); }); chat2.addEventListener('click', () => { animateChatSwitch(() => { chatName.textContent = 'Общий чат 2'; + publicChatPanel.activate(); }); }); } diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 184c0ef..b3b10de 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -32,6 +32,15 @@ export function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } + +export function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); } +export function ub64(s: string): Uint8Array { + const bin = atob(s); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr; +} + export function id(id: string): T { return document.getElementById(id) as unknown as T } \ No newline at end of file diff --git a/frontend/src/websocket.ts b/frontend/src/websocket.ts index 28b0199..afd28a2 100644 --- a/frontend/src/websocket.ts +++ b/frontend/src/websocket.ts @@ -7,6 +7,7 @@ import { handleWebSocketMessage } from "./chat/chat"; import { API_WS_BASE_URL } from "./core/config"; +import type { WebSocketMessage } from "./core/types"; import { delay } from "./utils/utils"; /** @@ -29,6 +30,20 @@ function create(): WebSocket { */ export let websocket: WebSocket = create(); +export function request(payload: WebSocketMessage): Promise { + return new Promise((resolve, reject) => { + let listener: ((e: MessageEvent) => void) | null = null; + listener = (e) => { + resolve(JSON.parse(e.data)); + websocket.removeEventListener("message", listener!); + } + websocket.addEventListener("message", listener); + websocket.send(JSON.stringify(payload)) + + setTimeout(() => reject("Request timed out"), 10000); + }) +} + /** * This function will wait 3 seconds and them attempts to reconnect the WebSocket. * If it fails, tries again in an endless loop until the connection is established diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 28db92d..cd5be70 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -14,6 +14,7 @@ "noEmit": true, /* Linting */ + "strict": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true diff --git a/package.json b/package.json index 3d590b4..f06de8f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "vite-plugin-html": "^3.2.2" }, "dependencies": { - "mdui": "^2.1.4" + "mdui": "^2.1.4", + "tweetnacl": "^1.0.3" } }