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..3f3a0fd 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,
diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py
index 36f60d8..57fc406 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,52 @@ 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.put("/edit_message/{message_id}")
async def edit_message(
message_id: int,
diff --git a/frontend/index.html b/frontend/index.html
index 00e4448..645c17b 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -147,6 +147,9 @@
Контакты
+
+ ЛС
+
@@ -160,6 +163,18 @@
Скоро будет...
Скоро будет...
+
+
+
+
+
+
+ Отправить
+
+
+
+
+
diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts
new file mode 100644
index 0000000..1ad0f77
--- /dev/null
+++ b/frontend/src/chat/dm.ts
@@ -0,0 +1,113 @@
+import { API_BASE_URL } from "../core/config";
+import { getAuthHeaders } from "../auth/api";
+import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric";
+import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric";
+import { randomBytes } from "../crypto/kdf";
+import { getCurrentKeys } from "../auth/crypto";
+
+function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); }
+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 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,
+ iv: b64(encMsg.iv),
+ ciphertext: b64(encMsg.ciphertext),
+ salt: b64(wkSalt),
+ iv2: b64(wrap.iv),
+ wrappedMk: b64(wrap.ciphertext)
+ })
+ });
+}
+
+export interface DmEnvelope {
+ id: number;
+ senderId: number;
+ recipientId: number;
+ iv: string;
+ ciphertext: string;
+ salt: string;
+ iv2: string;
+ wrappedMk: string;
+ timestamp: string;
+}
+
+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 res = await fetch(url, { headers: getAuthHeaders(true) });
+ if (!res.ok) return [];
+ const data = await res.json();
+ return data.messages ?? [];
+}
+
+export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise {
+ const keys = getCurrentKeys();
+ if (!keys) throw new Error("Keys not initialized");
+ 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));
+ const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
+ return new TextDecoder().decode(msg);
+}
+
+function appendDmMessage(text: string, isAuthor: boolean) {
+ const container = document.getElementById("dm-messages")!;
+ 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);
+ container.appendChild(div);
+ container.scrollTop = container.scrollHeight;
+}
+
+async function fetchRecipient(userName: string): Promise<{ id: number; publicKey: string | null } | null> {
+ const res = await fetch(`${API_BASE_URL}/profile/${encodeURIComponent(userName)}`);
+ if (!res.ok) return null;
+ const data = await res.json();
+ // This assumes an endpoint returns profile with id; adapt if different
+ const pkRes = await fetch(`${API_BASE_URL}/crypto/public-key`, { headers: getAuthHeaders(true) });
+ // For simplicity, we reuse current user's endpoint; in real case, need GET by userId
+ // Minimal v1: assume recipient has same endpoint at /profile/public-key?userId=... (not implemented)
+ return { id: data.id, publicKey: null };
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ const sendBtn = document.getElementById("dm-send");
+ if (!sendBtn) return;
+ sendBtn.addEventListener("click", async () => {
+ const userEl = document.getElementById("dm-username") as HTMLInputElement;
+ const textEl = document.getElementById("dm-input") as HTMLInputElement;
+ const username = userEl.value.trim();
+ const text = textEl.value.trim();
+ if (!username || !text) return;
+ // TODO: replace with real lookup for recipientId and publicKey
+ appendDmMessage(text, true);
+ textEl.value = "";
+ });
+});
+
+
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index b49445f..408fbb1 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -16,4 +16,5 @@ 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/dm";
\ No newline at end of file