mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement Signal Protocol
This commit is contained in:
@@ -71,6 +71,15 @@ class CryptoBackup(Base):
|
|||||||
blob_json = Column(Text, nullable=False)
|
blob_json = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalPreKeyBundle(Base):
|
||||||
|
__tablename__ = "signal_prekey_bundle"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||||
|
bundle_json = Column(Text, nullable=False) # JSON string of PreKeyBundleData
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
class DMEnvelope(Base):
|
class DMEnvelope(Base):
|
||||||
__tablename__ = "dm_envelope"
|
__tablename__ = "dm_envelope"
|
||||||
|
|
||||||
|
|||||||
@@ -479,6 +479,95 @@ def get_public_key_of(request: Request, user_id: int, current_user: User = Depen
|
|||||||
return {"publicKey": row.public_key_b64 if row else None}
|
return {"publicKey": row.public_key_b64 if row else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crypto/signal/prekey-bundle")
|
||||||
|
@rate_limit_per_ip("10/minute")
|
||||||
|
def upload_prekey_bundle(
|
||||||
|
request: Request,
|
||||||
|
payload: dict,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Upload Signal Protocol prekey bundle for the current user"""
|
||||||
|
from models import SignalPreKeyBundle
|
||||||
|
import json
|
||||||
|
|
||||||
|
bundle = payload.get("bundle")
|
||||||
|
if not bundle:
|
||||||
|
raise HTTPException(status_code=400, detail="bundle required")
|
||||||
|
|
||||||
|
# Validate bundle structure
|
||||||
|
if not isinstance(bundle, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="bundle must be a JSON object")
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
required_fields = ["registrationId", "identityKey", "signedPreKey"]
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in bundle:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
|
||||||
|
|
||||||
|
if not isinstance(bundle["signedPreKey"], dict) or "keyId" not in bundle["signedPreKey"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
|
||||||
|
|
||||||
|
# Store as JSON string
|
||||||
|
bundle_json = json.dumps(bundle)
|
||||||
|
if len(bundle_json) > 50000: # 50KB limit
|
||||||
|
raise HTTPException(status_code=400, detail="Bundle too large")
|
||||||
|
|
||||||
|
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
||||||
|
if row:
|
||||||
|
row.bundle_json = bundle_json
|
||||||
|
row.updated_at = datetime.now()
|
||||||
|
else:
|
||||||
|
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/crypto/signal/prekey-bundle")
|
||||||
|
def get_prekey_bundle(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get Signal Protocol prekey bundle for the current user"""
|
||||||
|
from models import SignalPreKeyBundle
|
||||||
|
import json
|
||||||
|
|
||||||
|
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Prekey bundle not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
bundle = json.loads(row.bundle_json)
|
||||||
|
return {"bundle": bundle}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(status_code=500, detail="Invalid bundle data")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/crypto/signal/prekey-bundle/of/{user_id}")
|
||||||
|
@rate_limit_per_ip("100/minute")
|
||||||
|
def get_prekey_bundle_of(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get Signal Protocol prekey bundle for another user"""
|
||||||
|
from models import SignalPreKeyBundle
|
||||||
|
import json
|
||||||
|
|
||||||
|
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == user_id).first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Prekey bundle not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
bundle = json.loads(row.bundle_json)
|
||||||
|
return {"bundle": bundle}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(status_code=500, detail="Invalid bundle data")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/search")
|
@router.get("/users/search")
|
||||||
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
|
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
|
||||||
def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export async function deriveAuthSecret(username: string, password: string): Prom
|
|||||||
return b64(derived);
|
return b64(derived);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
||||||
// Try to restore from backup
|
// Try to restore from backup
|
||||||
const blobJson = await fetchBackupBlob(token);
|
const blobJson = await fetchBackupBlob(token);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { API_BASE_URL } from "@/core/config";
|
|||||||
import { getAuthHeaders } from "./account";
|
import { getAuthHeaders } from "./account";
|
||||||
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
|
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the current user's public key
|
* Fetches the current user's public key
|
||||||
@@ -74,3 +75,32 @@ export async function uploadBackupBlob(blobJson: string, token: string): Promise
|
|||||||
if (!res.ok) throw new Error("Failed to upload backup blob");
|
if (!res.ok) throw new Error("Failed to upload backup blob");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads Signal Protocol prekey bundle for the current user
|
||||||
|
*/
|
||||||
|
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
|
||||||
|
const payload = { bundle };
|
||||||
|
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to upload prekey bundle");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches Signal Protocol prekey bundle for another user
|
||||||
|
*/
|
||||||
|
export async function fetchPreKeyBundle(userId: number, token: string): Promise<any | null> {
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return data.bundle || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-176
@@ -1,178 +1,16 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
// Re-export from dmApi.ts which has Signal Protocol support
|
||||||
import { getAuthHeaders } from "./account";
|
export {
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
decryptDm,
|
||||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
fetchDMHistory,
|
||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
sendDMViaWebSocket,
|
||||||
import { getCurrentKeys } from "./account";
|
sendDmWithFiles,
|
||||||
import { request } from "@/core/websocket";
|
editDmEnvelope,
|
||||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
deleteDmEnvelope,
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
fetchDMConversations,
|
||||||
import { fetchUserPublicKey } from "./crypto";
|
fetchUsers,
|
||||||
import { fetchUsers, searchUsers } from "./users";
|
searchUsers,
|
||||||
|
fetchUserPublicKey
|
||||||
|
} from "./dmApi";
|
||||||
|
|
||||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
export type { DMConversationResponse } from "./dmApi";
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!response.ok) return [];
|
|
||||||
const data = await response.json();
|
|
||||||
return data.messages || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export user functions for convenience
|
|
||||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
|
||||||
|
|
||||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
// Encryption key
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Encrypt the message
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
const payload: SendDMRequest = {
|
|
||||||
recipientId: recipientId,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
salt: b64(wkSalt),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
|
||||||
};
|
|
||||||
if (replyToId) payload.replyToId = replyToId;
|
|
||||||
|
|
||||||
await request({
|
|
||||||
type: "dmSend",
|
|
||||||
credentials: {
|
|
||||||
scheme: "Bearer",
|
|
||||||
credentials: authToken
|
|
||||||
},
|
|
||||||
data: payload
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
const names: string[] = [];
|
|
||||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
|
||||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const f of files) {
|
|
||||||
// Encrypt file with same mk
|
|
||||||
const data = new Uint8Array(await f.arrayBuffer());
|
|
||||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
|
||||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
|
||||||
const serverName = f.name; // server uses provided name
|
|
||||||
names.push(serverName);
|
|
||||||
form.append("files", new File([blob], serverName));
|
|
||||||
}
|
|
||||||
form.append("fileNames", JSON.stringify(names));
|
|
||||||
|
|
||||||
// Merge files metadata into plaintext JSON and encrypt
|
|
||||||
let obj: DmEncryptedJSON;
|
|
||||||
try {
|
|
||||||
obj = JSON.parse(plaintextJson);
|
|
||||||
} catch {
|
|
||||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
|
||||||
form.append("dm_payload", JSON.stringify({
|
|
||||||
recipientId: recipientId,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
salt: b64(wkSalt),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
|
||||||
} satisfies BaseDmEnvelope));
|
|
||||||
|
|
||||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(token, false),
|
|
||||||
body: form
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = await 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(newPlaintextJson));
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
await request({
|
|
||||||
type: "dmEdit",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: {
|
|
||||||
id,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext),
|
|
||||||
salt: b64(wkSalt)
|
|
||||||
}
|
|
||||||
} as DMEditRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
|
||||||
await request({
|
|
||||||
type: "dmDelete",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: { id, recipientId }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DMConversationResponse {
|
|
||||||
user: User;
|
|
||||||
lastMessage: DmEnvelope;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return data.conversations || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import { getAuthHeaders } from "./account";
|
import { getAuthHeaders } from "./account";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
|
||||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
|
||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
import { randomBytes } from "@/utils/crypto/kdf";
|
||||||
import { getCurrentKeys } from "./account";
|
import { getCurrentKeys } from "./account";
|
||||||
import { request } from "@/core/websocket";
|
import { request } from "@/core/websocket";
|
||||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
import { fetchUserPublicKey } from "./crypto";
|
import { fetchUserPublicKey, fetchPreKeyBundle } from "./crypto";
|
||||||
import { fetchUsers, searchUsers } from "./users";
|
import { fetchUsers, searchUsers } from "./users";
|
||||||
|
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||||
|
import { useUserStore } from "@/state/user";
|
||||||
|
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||||
|
|
||||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise<string> {
|
||||||
const keys = getCurrentKeys();
|
const user = useUserStore.getState().user.currentUser;
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
if (!user?.id) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
// Obtain the key
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
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
|
// Parse Signal Protocol message
|
||||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
const signalCiphertext = JSON.parse(envelope.ciphertext);
|
||||||
return new TextDecoder().decode(msg);
|
if (!signalCiphertext.type || !signalCiphertext.body) {
|
||||||
|
throw new Error("Invalid Signal Protocol message format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
|
||||||
|
return plaintext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||||
@@ -34,31 +39,36 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe
|
|||||||
return data.messages || [];
|
return data.messages || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export user functions for convenience
|
|
||||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
|
||||||
|
|
||||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
export async function sendDMViaWebSocket(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||||
const keys = getCurrentKeys();
|
const user = useUserStore.getState().user.currentUser;
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
if (!user?.id) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
// Encryption key
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
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);
|
|
||||||
|
|
||||||
// Encrypt the message
|
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
const hasSession = await signalService.hasSession(recipientId);
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
if (!hasSession) {
|
||||||
|
// Fetch prekey bundle from server
|
||||||
|
const bundle = await fetchPreKeyBundle(recipientId, authToken);
|
||||||
|
if (!bundle) {
|
||||||
|
throw new Error("No Signal Protocol prekey bundle available for recipient");
|
||||||
|
}
|
||||||
|
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt with Signal Protocol
|
||||||
|
const ciphertext = await signalService.encryptMessage(recipientId, plaintext);
|
||||||
|
|
||||||
const payload: SendDMRequest = {
|
const payload: SendDMRequest = {
|
||||||
recipientId: recipientId,
|
recipientId: recipientId,
|
||||||
iv: b64(encMsg.iv),
|
iv: "", // Not used for Signal Protocol
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
ciphertext: JSON.stringify(ciphertext), // Store Signal Protocol message as JSON
|
||||||
salt: b64(wkSalt),
|
salt: "", // Not used for Signal Protocol
|
||||||
iv2: b64(wrap.iv),
|
iv2: "", // Not used for Signal Protocol
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
wrappedMk: "" // Not used for Signal Protocol
|
||||||
};
|
};
|
||||||
if (replyToId) payload.replyToId = replyToId;
|
if (replyToId) payload.replyToId = replyToId;
|
||||||
|
|
||||||
@@ -78,7 +88,7 @@ export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64
|
|||||||
|
|
||||||
const mk = randomBytes(32);
|
const mk = randomBytes(32);
|
||||||
const wkSalt = randomBytes(16);
|
const wkSalt = randomBytes(16);
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
const wk = await importAesGcmKey(wkRaw);
|
||||||
|
|
||||||
@@ -133,7 +143,7 @@ export async function editDmEnvelope(id: number, recipientPublicKeyB64: string,
|
|||||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
||||||
const mk = randomBytes(32);
|
const mk = randomBytes(32);
|
||||||
const wkSalt = randomBytes(16);
|
const wkSalt = randomBytes(16);
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
const wk = await importAesGcmKey(wkRaw);
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||||
@@ -167,6 +177,9 @@ export interface DMConversationResponse {
|
|||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-export for convenience
|
||||||
|
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||||
|
|
||||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||||
headers: getAuthHeaders(token, true)
|
headers: getAuthHeaders(token, true)
|
||||||
|
|||||||
@@ -1,29 +1,15 @@
|
|||||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
|
||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
import { randomBytes } from "@/utils/crypto/kdf";
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||||
import api from "@/core/api";
|
import { useUserStore } from "@/state/user";
|
||||||
import type { WrappedSessionKeyPayload } from "@/core/types";
|
import { fetchPreKeyBundle } from "@/core/api/crypto";
|
||||||
|
import { getAuthToken } from "@/core/api/account";
|
||||||
|
|
||||||
export interface CallSessionKey {
|
export interface CallSessionKey {
|
||||||
key: Uint8Array;
|
key: Uint8Array;
|
||||||
hash: string; // For emoji display
|
hash: string; // For emoji display
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CallKeyExchange {
|
|
||||||
type: "call_key_exchange";
|
|
||||||
sessionKeyHash: string;
|
|
||||||
encryptedSessionKey: EncryptedCallMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EncryptedCallMessage {
|
|
||||||
iv: string;
|
|
||||||
ciphertext: string;
|
|
||||||
salt: string;
|
|
||||||
iv2: string;
|
|
||||||
wrappedSessionKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a new call session key for end-to-end encryption
|
* Generates a new call session key for end-to-end encryption
|
||||||
* @returns Promise that resolves to a session key with its hash for display
|
* @returns Promise that resolves to a session key with its hash for display
|
||||||
@@ -60,97 +46,6 @@ export async function rotateCallSessionKey(): Promise<CallSessionKey> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create session key from hash (for backward compatibility)
|
|
||||||
* @deprecated Use deriveCallSessionKeyFromSharedSecret instead
|
|
||||||
*/
|
|
||||||
export async function createCallSessionKeyFromHash(hash: string): Promise<CallSessionKey> {
|
|
||||||
// For backward compatibility, generate a deterministic key from the hash
|
|
||||||
const hashBytes = ub64(hash);
|
|
||||||
const sessionKey = new Uint8Array(32);
|
|
||||||
|
|
||||||
// Repeat the hash bytes to fill 32 bytes
|
|
||||||
for (let i = 0; i < 32; i++) {
|
|
||||||
sessionKey[i] = hashBytes[i % hashBytes.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: sessionKey,
|
|
||||||
hash
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive session key from ECDH shared secret and session key hash
|
|
||||||
* This creates a deterministic but cryptographically secure key
|
|
||||||
*/
|
|
||||||
export async function deriveCallSessionKeyFromSharedSecret(
|
|
||||||
sharedSecret: Uint8Array,
|
|
||||||
sessionKeyHash: string,
|
|
||||||
isInitiator: boolean
|
|
||||||
): Promise<CallSessionKey> {
|
|
||||||
// Use HKDF to derive the session key from the shared secret
|
|
||||||
// Include the session key hash and role to ensure uniqueness
|
|
||||||
const info = new TextEncoder().encode(`call-session-${sessionKeyHash}-${isInitiator ? 'initiator' : 'receiver'}`);
|
|
||||||
const salt = new Uint8Array(32); // Zero salt for deterministic derivation
|
|
||||||
|
|
||||||
// Import the shared secret as a raw key for HKDF
|
|
||||||
const sharedKey = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
sharedSecret.buffer as ArrayBuffer,
|
|
||||||
{ name: 'HKDF' },
|
|
||||||
false,
|
|
||||||
['deriveKey']
|
|
||||||
);
|
|
||||||
|
|
||||||
// Derive the session key using HKDF
|
|
||||||
const sessionKey = await crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'HKDF',
|
|
||||||
hash: 'SHA-256',
|
|
||||||
salt: salt,
|
|
||||||
info: info
|
|
||||||
},
|
|
||||||
sharedKey,
|
|
||||||
{ name: 'AES-GCM', length: 256 },
|
|
||||||
true, // Make the key extractable so we can export it
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
);
|
|
||||||
|
|
||||||
// Export the raw key material
|
|
||||||
const sessionKeyMaterial = await crypto.subtle.exportKey('raw', sessionKey);
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: new Uint8Array(sessionKeyMaterial),
|
|
||||||
hash: sessionKeyHash
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt a call signaling message with the session key
|
|
||||||
*/
|
|
||||||
export async function encryptCallMessage(message: Record<string, unknown>, sessionKey: Uint8Array): Promise<EncryptedCallMessage> {
|
|
||||||
const messageKey = await importAesGcmKey(sessionKey);
|
|
||||||
const encrypted = await aesGcmEncrypt(messageKey, new TextEncoder().encode(JSON.stringify(message)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
iv: b64(encrypted.iv),
|
|
||||||
ciphertext: b64(encrypted.ciphertext),
|
|
||||||
salt: "", // Not used for message encryption, only for key wrapping
|
|
||||||
iv2: "",
|
|
||||||
wrappedSessionKey: ""
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt a call signaling message
|
|
||||||
*/
|
|
||||||
export async function decryptCallMessage(encryptedMessage: EncryptedCallMessage, sessionKey: Uint8Array): Promise<Record<string, unknown>> {
|
|
||||||
const messageKey = await importAesGcmKey(sessionKey);
|
|
||||||
const decrypted = await aesGcmDecrypt(messageKey, ub64(encryptedMessage.iv), ub64(encryptedMessage.ciphertext));
|
|
||||||
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate 4 emojis representing the call session key
|
* Generate 4 emojis representing the call session key
|
||||||
*/
|
*/
|
||||||
@@ -176,63 +71,65 @@ export function generateCallEmojis(sessionKeyHash: string): string[] {
|
|||||||
return emojis;
|
return emojis;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HKDF info for CALL key wrapping (distinct from DM's info)
|
|
||||||
const CALL_INFO = new Uint8Array([2]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps a call session key for a specific recipient using ECDH key exchange
|
* Encrypts a call session key using Signal Protocol
|
||||||
* @param recipientPublicKeyB64 - The recipient's public key in base64 format
|
* @param recipientId - The recipient's user ID
|
||||||
* @param sessionKey - The session key to wrap
|
* @param sessionKey - The session key to encrypt
|
||||||
* @returns Promise that resolves to the wrapped session key payload
|
* @returns Promise that resolves to encrypted session key data
|
||||||
*/
|
*/
|
||||||
export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise<WrappedSessionKeyPayload> {
|
export async function encryptCallSessionKey(recipientId: number, sessionKey: Uint8Array): Promise<{ type: number; body: string }> {
|
||||||
const keys = api.user.auth.getCurrentKeys();
|
const user = useUserStore.getState().user.currentUser;
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
if (!user?.id) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
const salt = randomBytes(16);
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
|
// Ensure we have a session with the recipient
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
const hasSession = await signalService.hasSession(recipientId);
|
||||||
const wrap = await aesGcmEncrypt(wk, sessionKey);
|
if (!hasSession) {
|
||||||
return {
|
// Fetch prekey bundle and establish session
|
||||||
salt: b64(salt),
|
const token = getAuthToken();
|
||||||
iv2: b64(wrap.iv),
|
if (!token) {
|
||||||
wrapped: b64(wrap.ciphertext)
|
throw new Error("No auth token");
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const bundle = await fetchPreKeyBundle(recipientId, token);
|
||||||
|
if (!bundle) {
|
||||||
|
throw new Error("No prekey bundle available for recipient");
|
||||||
|
}
|
||||||
|
|
||||||
|
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the session key using Signal Protocol
|
||||||
|
const sessionKeyString = b64(sessionKey);
|
||||||
|
const encrypted = await signalService.encryptMessage(recipientId, sessionKeyString);
|
||||||
|
|
||||||
|
return encrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a shared secret and derive session key for the receiver
|
* Decrypts a call session key using Signal Protocol
|
||||||
|
* @param senderId - The sender's user ID
|
||||||
|
* @param encryptedKey - The encrypted session key data
|
||||||
|
* @returns Promise that resolves to the decrypted session key
|
||||||
*/
|
*/
|
||||||
export async function createSharedSecretAndDeriveSessionKey(
|
export async function decryptCallSessionKey(senderId: number, encryptedKey: { type: number; body: string }): Promise<Uint8Array> {
|
||||||
senderPublicKeyB64: string,
|
const user = useUserStore.getState().user.currentUser;
|
||||||
sessionKeyHash: string,
|
if (!user?.id) {
|
||||||
isInitiator: boolean
|
throw new Error("User not authenticated");
|
||||||
): Promise<CallSessionKey> {
|
}
|
||||||
const keys = api.user.auth.getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
// Create shared secret using ECDH
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
const sharedSecret = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
|
||||||
|
|
||||||
// Derive the session key from the shared secret
|
// Decrypt using Signal Protocol
|
||||||
return await deriveCallSessionKeyFromSharedSecret(sharedSecret, sessionKeyHash, isInitiator);
|
const decryptedString = await signalService.decryptMessage(senderId, encryptedKey);
|
||||||
}
|
|
||||||
|
// Convert back to Uint8Array
|
||||||
/**
|
const sessionKey = new Uint8Array(
|
||||||
* Unwraps a call session key received from a sender using ECDH key exchange
|
atob(decryptedString).split("").map(c => c.charCodeAt(0))
|
||||||
* @param senderPublicKeyB64 - The sender's public key in base64 format
|
);
|
||||||
* @param payload - The wrapped session key payload
|
|
||||||
* @returns Promise that resolves to the unwrapped session key
|
return sessionKey;
|
||||||
*/
|
|
||||||
export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise<Uint8Array> {
|
|
||||||
const keys = api.user.auth.getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
const salt = ub64(payload.salt);
|
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
const sessionKey = await aesGcmDecrypt(wk, ub64(payload.iv2), ub64(payload.wrapped));
|
|
||||||
return new Uint8Array(sessionKey);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData } from "@/core/types";
|
import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData, CallSessionKeyData } from "@/core/types";
|
||||||
import * as WebRTC from "./webrtc";
|
import * as WebRTC from "./webrtc";
|
||||||
|
|
||||||
export interface CallState {
|
export interface CallState {
|
||||||
@@ -133,12 +133,16 @@ export class CallSignalingHandler {
|
|||||||
|
|
||||||
private handleCallSessionKey(message: CallSignalingMessage) {
|
private handleCallSessionKey(message: CallSignalingMessage) {
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
const { sessionKeyHash, data } = message;
|
const { sessionKeyHash } = message;
|
||||||
|
const data = message.data as CallSessionKeyData;
|
||||||
|
|
||||||
if (sessionKeyHash) {
|
if (sessionKeyHash) {
|
||||||
state.setCallSessionKeyHash(sessionKeyHash);
|
state.setCallSessionKeyHash(sessionKeyHash);
|
||||||
}
|
}
|
||||||
if (data && 'wrappedSessionKey' in data && data.wrappedSessionKey && message.fromUserId) {
|
|
||||||
WebRTC.receiveWrappedSessionKey(message.fromUserId, data.wrappedSessionKey, sessionKeyHash);
|
// Check if data is CallSessionKeyData and has encryptedSessionKey
|
||||||
|
if (data && data.encryptedSessionKey && message.fromUserId) {
|
||||||
|
WebRTC.receiveWrappedSessionKey(message.fromUserId, data.encryptedSessionKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import api from "@/core/api";
|
import api from "@/core/api";
|
||||||
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types";
|
import type { CallSignalingMessage } from "@/core/types";
|
||||||
import { request } from "@/core/websocket";
|
import { request } from "@/core/websocket";
|
||||||
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption";
|
import { encryptCallSessionKey, decryptCallSessionKey, rotateCallSessionKey } from "./encryption";
|
||||||
import { importAesGcmKey } from "@/utils/crypto/symmetric";
|
import { importAesGcmKey } from "@/utils/crypto/symmetric";
|
||||||
import E2EEWorker from "./e2eeWorker?worker";
|
import E2EEWorker from "./e2eeWorker?worker";
|
||||||
import { delay } from "@/utils/utils";
|
import { delay } from "@/utils/utils";
|
||||||
@@ -855,21 +855,18 @@ export async function sendCallSessionKey(userId: number, sessionKeyHash: string)
|
|||||||
|
|
||||||
export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise<void> {
|
export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const recipientPublicKey = await api.chats.dm.fetchUserPublicKey(userId, api.user.auth.getAuthToken()!);
|
// Encrypt session key using Signal Protocol
|
||||||
if (!recipientPublicKey) {
|
const encrypted = await encryptCallSessionKey(userId, sessionKey);
|
||||||
console.warn("No recipient public key for", userId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const wrapped = await wrapCallSessionKeyForRecipient(recipientPublicKey, sessionKey);
|
|
||||||
await sendSignalingMessage({
|
await sendSignalingMessage({
|
||||||
type: "call_session_key",
|
type: "call_session_key",
|
||||||
fromUserId: 0,
|
fromUserId: 0,
|
||||||
toUserId: userId,
|
toUserId: userId,
|
||||||
sessionKeyHash,
|
sessionKeyHash,
|
||||||
data: { wrappedSessionKey: wrapped }
|
data: { encryptedSessionKey: encrypted }
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to send wrapped session key:", e);
|
console.error("Failed to send encrypted session key:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,31 +883,21 @@ export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promi
|
|||||||
|
|
||||||
export async function receiveWrappedSessionKey(
|
export async function receiveWrappedSessionKey(
|
||||||
fromUserId: number,
|
fromUserId: number,
|
||||||
wrappedPayload: WrappedSessionKeyPayload,
|
encryptedKey: { type: number; body: string }
|
||||||
sessionKeyHash?: string
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const senderPublicKey = await api.chats.dm.fetchUserPublicKey(fromUserId, api.user.auth.getAuthToken()!);
|
if (!encryptedKey) {
|
||||||
if (!senderPublicKey) {
|
console.error("Missing encrypted session key");
|
||||||
console.error("Failed to get sender public key");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!wrappedPayload || !sessionKeyHash) {
|
|
||||||
console.error("Missing wrapped payload or session key hash");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap the session key from the encrypted payload
|
// Decrypt the session key using Signal Protocol
|
||||||
const unwrappedSessionKey = await unwrapCallSessionKeyFromSender(senderPublicKey, {
|
const sessionKey = await decryptCallSessionKey(fromUserId, encryptedKey);
|
||||||
salt: wrappedPayload.salt,
|
|
||||||
iv2: wrappedPayload.iv2,
|
|
||||||
wrapped: wrappedPayload.wrapped
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use the unwrapped session key directly (both sides should have the same key)
|
// Use the decrypted session key for media encryption
|
||||||
await setSessionKey(fromUserId, unwrappedSessionKey);
|
await setSessionKey(fromUserId, sessionKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to unwrap session key:", e);
|
console.error("Failed to decrypt session key:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-7
@@ -505,7 +505,7 @@ export interface CallEndData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CallSessionKeyData {
|
export interface CallSessionKeyData {
|
||||||
wrappedSessionKey?: WrappedSessionKeyPayload;
|
encryptedSessionKey: { type: number; body: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CallVideoToggleData {
|
export interface CallVideoToggleData {
|
||||||
@@ -526,12 +526,6 @@ export interface CallScreenShareToggleMessageData {
|
|||||||
data: CallScreenShareToggleData;
|
data: CallScreenShareToggleData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WrappedSessionKeyPayload {
|
|
||||||
salt: string;
|
|
||||||
iv2: string;
|
|
||||||
wrapped: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CallVideoToggleMessage extends CallSignalingMessage {
|
export interface CallVideoToggleMessage extends CallSignalingMessage {
|
||||||
type: "call_video_toggle";
|
type: "call_video_toggle";
|
||||||
data: CallVideoToggleData;
|
data: CallVideoToggleData;
|
||||||
|
|||||||
@@ -92,6 +92,16 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
||||||
|
|
||||||
|
// Initialize Signal Protocol after keys are set up
|
||||||
|
if (data.user?.id) {
|
||||||
|
const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol");
|
||||||
|
const { uploadPreKeyBundle } = await import("@/core/api/crypto");
|
||||||
|
const signalService = new SignalProtocolService(data.user.id.toString());
|
||||||
|
await signalService.initialize();
|
||||||
|
const bundle = await signalService.getPreKeyBundle();
|
||||||
|
await uploadPreKeyBundle(bundle, data.token);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Key setup failed:", e);
|
console.error("Key setup failed:", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
|||||||
import { useUserStore } from "@/state/user";
|
import { useUserStore } from "@/state/user";
|
||||||
import { useChatStore } from "@/state/chat";
|
import { useChatStore } from "@/state/chat";
|
||||||
import api from "@/core/api";
|
import api from "@/core/api";
|
||||||
|
import { decryptDm, sendDMViaWebSocket } from "@/core/api/dm";
|
||||||
import type { ConversationResponse } from "@/core/api/chats/dm";
|
import type { ConversationResponse } from "@/core/api/chats/dm";
|
||||||
import type { User, Message, DmEncryptedJSON } from "@/core/types";
|
import type { User, Message, DmEncryptedJSON } from "@/core/types";
|
||||||
import { websocket } from "@/core/websocket";
|
import { websocket } from "@/core/websocket";
|
||||||
@@ -64,7 +65,7 @@ export function useDM() {
|
|||||||
let lastPlaintext: string | null = null;
|
let lastPlaintext: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
lastPlaintext = (JSON.parse(await api.chats.dm.decrypt(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
|
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, lastMessage.senderId)) as DmEncryptedJSON).data.content;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to decrypt last message:", error);
|
console.error("Failed to decrypt last message:", error);
|
||||||
}
|
}
|
||||||
@@ -118,7 +119,7 @@ export function useDM() {
|
|||||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||||
if (publicKey) {
|
if (publicKey) {
|
||||||
// Decrypt the last message
|
// Decrypt the last message
|
||||||
const decryptedJson = await api.chats.dm.decrypt(conv.lastMessage, publicKey!);
|
const decryptedJson = await decryptDm(conv.lastMessage, conv.lastMessage.senderId);
|
||||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||||
lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!);
|
lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!);
|
||||||
}
|
}
|
||||||
@@ -152,7 +153,7 @@ export function useDM() {
|
|||||||
}, [user.authToken]);
|
}, [user.authToken]);
|
||||||
|
|
||||||
// Load DM history for active conversation
|
// Load DM history for active conversation
|
||||||
const loadDMHistory = useCallback(async (userId: number, publicKey: string) => {
|
const loadDMHistory = useCallback(async (userId: number) => {
|
||||||
if (!user.authToken || isLoadingHistory) return;
|
if (!user.authToken || isLoadingHistory) return;
|
||||||
|
|
||||||
setIsLoadingHistory(true);
|
setIsLoadingHistory(true);
|
||||||
@@ -163,7 +164,7 @@ export function useDM() {
|
|||||||
|
|
||||||
for (const env of messages) {
|
for (const env of messages) {
|
||||||
try {
|
try {
|
||||||
const text = await api.chats.dm.decrypt(env, publicKey);
|
const text = await decryptDm(env, env.senderId);
|
||||||
const isAuthor = env.senderId !== userId;
|
const isAuthor = env.senderId !== userId;
|
||||||
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
|
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
|
||||||
|
|
||||||
@@ -204,11 +205,11 @@ export function useDM() {
|
|||||||
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
|
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
|
||||||
|
|
||||||
// Send DM message
|
// Send DM message
|
||||||
const sendDMMessage = useCallback(async (recipientId: number, publicKey: string, content: string) => {
|
const sendDMMessage = useCallback(async (recipientId: number, content: string) => {
|
||||||
if (!user.authToken) return;
|
if (!user.authToken) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.chats.dm.send(recipientId, publicKey, content, user.authToken);
|
await sendDMViaWebSocket(recipientId, content, user.authToken);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send DM:", error);
|
console.error("Failed to send DM:", error);
|
||||||
}
|
}
|
||||||
@@ -234,7 +235,7 @@ export function useDM() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load conversation history
|
// Load conversation history
|
||||||
await loadDMHistory(dmUser.id, publicKey);
|
await loadDMHistory(dmUser.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start DM conversation:", error);
|
console.error("Failed to start DM conversation:", error);
|
||||||
}
|
}
|
||||||
@@ -267,7 +268,7 @@ export function useDM() {
|
|||||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||||
if (publicKey) {
|
if (publicKey) {
|
||||||
// Decrypt the last message
|
// Decrypt the last message
|
||||||
const decryptedJson = await api.chats.dm.decrypt(userConversation.lastMessage, publicKey!);
|
const decryptedJson = await decryptDm(userConversation.lastMessage, userConversation.lastMessage.senderId);
|
||||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||||
lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser?.id!);
|
lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser?.id!);
|
||||||
}
|
}
|
||||||
@@ -318,7 +319,7 @@ export function useDM() {
|
|||||||
try {
|
try {
|
||||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||||
if (publicKey) {
|
if (publicKey) {
|
||||||
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey);
|
const decryptedJson = await decryptDm(envelope, senderId);
|
||||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||||
const messageContent = decryptedData.data.content;
|
const messageContent = decryptedData.data.content;
|
||||||
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
|
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
|
||||||
@@ -348,7 +349,7 @@ export function useDM() {
|
|||||||
try {
|
try {
|
||||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||||
if (publicKey) {
|
if (publicKey) {
|
||||||
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey);
|
const decryptedJson = await decryptDm(envelope, senderId);
|
||||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||||
const messageContent = decryptedData.data.content;
|
const messageContent = decryptedData.data.content;
|
||||||
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
|
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { StateCreator } from "zustand";
|
||||||
|
import type { Message, User } from "@/core/types";
|
||||||
|
import { MessagePanel } from "../ui/right/panels/MessagePanel";
|
||||||
|
import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel";
|
||||||
|
import { DMPanel, type DMPanelData } from "../ui/right/panels/DMPanel";
|
||||||
|
import type { ChatState, ChatTabs, ActiveDM } from "./types";
|
||||||
|
import { useUserStore } from "@/state/user";
|
||||||
|
|
||||||
|
export interface ChatStateSlice {
|
||||||
|
chat: ChatState;
|
||||||
|
addMessage: (message: Message) => void;
|
||||||
|
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => void;
|
||||||
|
removeMessage: (messageId: number) => void;
|
||||||
|
setCurrentChat: (chat: string) => void;
|
||||||
|
setActiveTab: (tab: ChatTabs) => void;
|
||||||
|
setDmUsers: (users: User[]) => void;
|
||||||
|
setActiveDm: (dm: ActiveDM | null) => void;
|
||||||
|
clearMessages: () => void;
|
||||||
|
setActivePanel: (panel: MessagePanel | null) => void;
|
||||||
|
setPendingPanel: (panel: MessagePanel | null) => void;
|
||||||
|
applyPendingPanel: () => void;
|
||||||
|
switchToPublicChat: (chatName: string) => Promise<void>;
|
||||||
|
switchToDM: (dmData: DMPanelData) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createChatState: StateCreator<
|
||||||
|
ChatStateSlice & { user: { authToken: string | null } },
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ChatStateSlice
|
||||||
|
> = (set, get) => ({
|
||||||
|
chat: {
|
||||||
|
messages: [],
|
||||||
|
currentChat: "Общий чат",
|
||||||
|
activeTab: "chats",
|
||||||
|
dmUsers: [],
|
||||||
|
activeDm: null,
|
||||||
|
isSwitching: false,
|
||||||
|
setIsSwitching: (value: boolean) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
isSwitching: value
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
activePanel: null,
|
||||||
|
publicChatPanel: null,
|
||||||
|
dmPanel: null,
|
||||||
|
pendingPanel: null,
|
||||||
|
profileDialog: null,
|
||||||
|
call: {
|
||||||
|
isActive: false,
|
||||||
|
status: "ended",
|
||||||
|
startTime: null,
|
||||||
|
isMuted: false,
|
||||||
|
remoteUserId: null,
|
||||||
|
remoteUsername: null,
|
||||||
|
isInitiator: false,
|
||||||
|
isMinimized: false,
|
||||||
|
sessionKeyHash: null,
|
||||||
|
encryptionEmojis: [],
|
||||||
|
isVideoEnabled: false,
|
||||||
|
isRemoteVideoEnabled: false,
|
||||||
|
isSharingScreen: false,
|
||||||
|
isRemoteScreenSharing: false
|
||||||
|
},
|
||||||
|
onlineStatuses: new Map(),
|
||||||
|
typingUsers: new Map(),
|
||||||
|
dmTypingUsers: new Map()
|
||||||
|
},
|
||||||
|
addMessage: (message: Message) => set((state) => {
|
||||||
|
const messageExists = state.chat.messages.some(msg => msg.id === message.id);
|
||||||
|
if (messageExists) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: [...state.chat.messages, message]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: state.chat.messages.map(msg =>
|
||||||
|
msg.id === messageId ? { ...msg, ...updatedMessage } : msg
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
removeMessage: (messageId: number) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: state.chat.messages.filter(msg => msg.id !== messageId)
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
clearMessages: () => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: []
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setCurrentChat: (chat: string) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
currentChat: chat
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setActiveTab: (tab: ChatTabs) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activeTab: tab
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setDmUsers: (users: User[]) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
dmUsers: users
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setActiveDm: (dm: ActiveDM | null) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activeDm: dm
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setActivePanel: (panel: MessagePanel | null) => {
|
||||||
|
const state = get();
|
||||||
|
if (state.chat.activePanel && state.chat.activePanel !== panel) {
|
||||||
|
state.chat.activePanel.deactivate();
|
||||||
|
}
|
||||||
|
return set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activePanel: panel
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
pendingPanel: panel
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
applyPendingPanel: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.chat.activePanel) {
|
||||||
|
state.chat.activePanel.deactivate();
|
||||||
|
}
|
||||||
|
return set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activePanel: state.chat.pendingPanel || state.chat.activePanel,
|
||||||
|
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
|
||||||
|
? (state.chat.pendingPanel as PublicChatPanel)
|
||||||
|
: state.chat.publicChatPanel,
|
||||||
|
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
|
||||||
|
? (state.chat.pendingPanel as DMPanel)
|
||||||
|
: state.chat.dmPanel,
|
||||||
|
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
|
||||||
|
pendingPanel: null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
switchToPublicChat: async (chatName: string) => {
|
||||||
|
const { chat } = get();
|
||||||
|
const { user } = useUserStore();
|
||||||
|
|
||||||
|
if (!user.authToken) return;
|
||||||
|
|
||||||
|
chat.setIsSwitching(true);
|
||||||
|
|
||||||
|
let publicChatPanel = chat.publicChatPanel;
|
||||||
|
if (!publicChatPanel) {
|
||||||
|
publicChatPanel = new PublicChatPanel(chatName, user);
|
||||||
|
} else {
|
||||||
|
publicChatPanel.setChatName(chatName);
|
||||||
|
publicChatPanel.setAuthToken(user.authToken);
|
||||||
|
publicChatPanel.clearMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
await publicChatPanel.activate();
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
pendingPanel: publicChatPanel,
|
||||||
|
activeTab: "chats"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
switchToDM: async (dmData: DMPanelData) => {
|
||||||
|
const { chat } = get();
|
||||||
|
const { user } = useUserStore();
|
||||||
|
|
||||||
|
if (!user.authToken) return;
|
||||||
|
|
||||||
|
chat.setIsSwitching(true);
|
||||||
|
|
||||||
|
let dmPanel = chat.dmPanel;
|
||||||
|
if (!dmPanel) {
|
||||||
|
dmPanel = new DMPanel(user);
|
||||||
|
} else {
|
||||||
|
dmPanel.setAuthToken(user.authToken);
|
||||||
|
dmPanel.clearMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
dmPanel.setDMData(dmData);
|
||||||
|
|
||||||
|
await dmPanel.activate();
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
pendingPanel: dmPanel,
|
||||||
|
activeDm: {
|
||||||
|
userId: dmData.userId,
|
||||||
|
username: dmData.username,
|
||||||
|
publicKey: dmData.publicKey
|
||||||
|
},
|
||||||
|
activeTab: "chats"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { StateCreator } from "zustand";
|
||||||
|
import type { Message, User } from "@/core/types";
|
||||||
|
import { MessagePanel } from "../ui/right/panels/MessagePanel";
|
||||||
|
import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel";
|
||||||
|
import { DMPanel, type DMPanelData } from "../ui/right/panels/DMPanel";
|
||||||
|
import type { ChatState, ChatTabs, ActiveDM } from "./types";
|
||||||
|
import { useUserStore } from "@/state/user";
|
||||||
|
|
||||||
|
export interface ChatStateSlice {
|
||||||
|
chat: ChatState;
|
||||||
|
addMessage: (message: Message) => void;
|
||||||
|
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => void;
|
||||||
|
removeMessage: (messageId: number) => void;
|
||||||
|
setCurrentChat: (chat: string) => void;
|
||||||
|
setActiveTab: (tab: ChatTabs) => void;
|
||||||
|
setDmUsers: (users: User[]) => void;
|
||||||
|
setActiveDm: (dm: ActiveDM | null) => void;
|
||||||
|
clearMessages: () => void;
|
||||||
|
setActivePanel: (panel: MessagePanel | null) => void;
|
||||||
|
setPendingPanel: (panel: MessagePanel | null) => void;
|
||||||
|
applyPendingPanel: () => void;
|
||||||
|
switchToPublicChat: (chatName: string) => Promise<void>;
|
||||||
|
switchToDM: (dmData: DMPanelData) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createChatState: StateCreator<
|
||||||
|
ChatStateSlice & { user: { authToken: string | null } },
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ChatStateSlice
|
||||||
|
> = (set, get) => ({
|
||||||
|
chat: {
|
||||||
|
messages: [],
|
||||||
|
currentChat: "Общий чат",
|
||||||
|
activeTab: "chats",
|
||||||
|
dmUsers: [],
|
||||||
|
activeDm: null,
|
||||||
|
isSwitching: false,
|
||||||
|
setIsSwitching: (value: boolean) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
isSwitching: value
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
activePanel: null,
|
||||||
|
publicChatPanel: null,
|
||||||
|
dmPanel: null,
|
||||||
|
pendingPanel: null,
|
||||||
|
profileDialog: null,
|
||||||
|
call: {
|
||||||
|
isActive: false,
|
||||||
|
status: "ended",
|
||||||
|
startTime: null,
|
||||||
|
isMuted: false,
|
||||||
|
remoteUserId: null,
|
||||||
|
remoteUsername: null,
|
||||||
|
isInitiator: false,
|
||||||
|
isMinimized: false,
|
||||||
|
sessionKeyHash: null,
|
||||||
|
encryptionEmojis: [],
|
||||||
|
isVideoEnabled: false,
|
||||||
|
isRemoteVideoEnabled: false,
|
||||||
|
isSharingScreen: false,
|
||||||
|
isRemoteScreenSharing: false
|
||||||
|
},
|
||||||
|
onlineStatuses: new Map(),
|
||||||
|
typingUsers: new Map(),
|
||||||
|
dmTypingUsers: new Map()
|
||||||
|
},
|
||||||
|
addMessage: (message: Message) => set((state) => {
|
||||||
|
const messageExists = state.chat.messages.some(msg => msg.id === message.id);
|
||||||
|
if (messageExists) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: [...state.chat.messages, message]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: state.chat.messages.map(msg =>
|
||||||
|
msg.id === messageId ? { ...msg, ...updatedMessage } : msg
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
removeMessage: (messageId: number) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: state.chat.messages.filter(msg => msg.id !== messageId)
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
clearMessages: () => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: []
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setCurrentChat: (chat: string) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
currentChat: chat
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setActiveTab: (tab: ChatTabs) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activeTab: tab
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setDmUsers: (users: User[]) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
dmUsers: users
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setActiveDm: (dm: ActiveDM | null) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activeDm: dm
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setActivePanel: (panel: MessagePanel | null) => {
|
||||||
|
const state = get();
|
||||||
|
if (state.chat.activePanel && state.chat.activePanel !== panel) {
|
||||||
|
state.chat.activePanel.deactivate();
|
||||||
|
}
|
||||||
|
return set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activePanel: panel
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
pendingPanel: panel
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
applyPendingPanel: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.chat.activePanel) {
|
||||||
|
state.chat.activePanel.deactivate();
|
||||||
|
}
|
||||||
|
return set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activePanel: state.chat.pendingPanel || state.chat.activePanel,
|
||||||
|
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
|
||||||
|
? (state.chat.pendingPanel as PublicChatPanel)
|
||||||
|
: state.chat.publicChatPanel,
|
||||||
|
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
|
||||||
|
? (state.chat.pendingPanel as DMPanel)
|
||||||
|
: state.chat.dmPanel,
|
||||||
|
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
|
||||||
|
pendingPanel: null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
switchToPublicChat: async (chatName: string) => {
|
||||||
|
const { chat } = get();
|
||||||
|
const { user } = useUserStore();
|
||||||
|
|
||||||
|
if (!user.authToken) return;
|
||||||
|
|
||||||
|
chat.setIsSwitching(true);
|
||||||
|
|
||||||
|
let publicChatPanel = chat.publicChatPanel;
|
||||||
|
if (!publicChatPanel) {
|
||||||
|
publicChatPanel = new PublicChatPanel(chatName, user);
|
||||||
|
} else {
|
||||||
|
publicChatPanel.setChatName(chatName);
|
||||||
|
publicChatPanel.setAuthToken(user.authToken);
|
||||||
|
publicChatPanel.clearMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
await publicChatPanel.activate();
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
pendingPanel: publicChatPanel,
|
||||||
|
activeTab: "chats"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
switchToDM: async (dmData: DMPanelData) => {
|
||||||
|
const { chat } = get();
|
||||||
|
const { user } = useUserStore();
|
||||||
|
|
||||||
|
if (!user.authToken) return;
|
||||||
|
|
||||||
|
chat.setIsSwitching(true);
|
||||||
|
|
||||||
|
let dmPanel = chat.dmPanel;
|
||||||
|
if (!dmPanel) {
|
||||||
|
dmPanel = new DMPanel(user);
|
||||||
|
} else {
|
||||||
|
dmPanel.setAuthToken(user.authToken);
|
||||||
|
dmPanel.clearMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
dmPanel.setDMData(dmData);
|
||||||
|
|
||||||
|
await dmPanel.activate();
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
pendingPanel: dmPanel,
|
||||||
|
activeDm: {
|
||||||
|
userId: dmData.userId,
|
||||||
|
username: dmData.username,
|
||||||
|
publicKey: dmData.publicKey
|
||||||
|
},
|
||||||
|
activeTab: "chats"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Message, User } from "@/core/types";
|
||||||
|
import { MessagePanel } from "../ui/right/panels/MessagePanel";
|
||||||
|
import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel";
|
||||||
|
import { DMPanel } from "../ui/right/panels/DMPanel";
|
||||||
|
|
||||||
|
export type ChatTabs = "chats" | "channels" | "contacts";
|
||||||
|
|
||||||
|
export type CallStatus = "calling" | "connecting" | "active" | "ended";
|
||||||
|
|
||||||
|
export interface ProfileDialogData {
|
||||||
|
userId?: number;
|
||||||
|
username?: string;
|
||||||
|
display_name?: string;
|
||||||
|
profilePicture?: string;
|
||||||
|
bio?: string;
|
||||||
|
memberSince?: string;
|
||||||
|
online?: boolean;
|
||||||
|
isOwnProfile: boolean;
|
||||||
|
verified?: boolean;
|
||||||
|
suspended?: boolean;
|
||||||
|
suspension_reason?: string | null;
|
||||||
|
deleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveDM {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
publicKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallState {
|
||||||
|
isActive: boolean;
|
||||||
|
status: CallStatus;
|
||||||
|
startTime: number | null;
|
||||||
|
isMuted: boolean;
|
||||||
|
remoteUserId: number | null;
|
||||||
|
remoteUsername: string | null;
|
||||||
|
isInitiator: boolean;
|
||||||
|
isMinimized: boolean;
|
||||||
|
sessionKeyHash: string | null;
|
||||||
|
encryptionEmojis: string[];
|
||||||
|
isVideoEnabled: boolean;
|
||||||
|
isRemoteVideoEnabled: boolean;
|
||||||
|
isSharingScreen: boolean;
|
||||||
|
isRemoteScreenSharing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatState {
|
||||||
|
messages: Message[];
|
||||||
|
currentChat: string;
|
||||||
|
activeTab: ChatTabs;
|
||||||
|
dmUsers: User[];
|
||||||
|
activeDm: ActiveDM | null;
|
||||||
|
isSwitching: boolean;
|
||||||
|
setIsSwitching: (value: boolean) => void;
|
||||||
|
activePanel: MessagePanel | null;
|
||||||
|
publicChatPanel: PublicChatPanel | null;
|
||||||
|
dmPanel: DMPanel | null;
|
||||||
|
pendingPanel?: MessagePanel | null;
|
||||||
|
call: CallState;
|
||||||
|
profileDialog: ProfileDialogData | null;
|
||||||
|
onlineStatuses: Map<number, {online: boolean, lastSeen: string}>;
|
||||||
|
typingUsers: Map<number, string>; // userId -> username
|
||||||
|
dmTypingUsers: Map<number, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserState {
|
||||||
|
currentUser: User | null;
|
||||||
|
authToken: string | null;
|
||||||
|
isSuspended: boolean;
|
||||||
|
suspensionReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { MessagePanel } from "./MessagePanel";
|
import { MessagePanel } from "./MessagePanel";
|
||||||
import api from "@/core/api";
|
import api from "@/core/api";
|
||||||
|
import { decryptDm, sendDMViaWebSocket, sendDmWithFiles } from "@/core/api/dm";
|
||||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
||||||
import type { UserState, ProfileDialogData } from "@/state/types";
|
import type { UserState, ProfileDialogData } from "@/state/types";
|
||||||
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
||||||
@@ -55,7 +56,7 @@ export class DMPanel extends MessagePanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
||||||
const plaintext = await api.chats.dm.decrypt(env, this.dmData!.publicKey);
|
const plaintext = await decryptDm(env, env.senderId);
|
||||||
const username = formatDMUsername(
|
const username = formatDMUsername(
|
||||||
env.senderId,
|
env.senderId,
|
||||||
env.recipientId,
|
env.recipientId,
|
||||||
@@ -193,21 +194,24 @@ export class DMPanel extends MessagePanel {
|
|||||||
}
|
}
|
||||||
const json = JSON.stringify(payload);
|
const json = JSON.stringify(payload);
|
||||||
|
|
||||||
if (files.length === 0) {
|
try {
|
||||||
await api.chats.dm.send(
|
if (files.length === 0) {
|
||||||
this.dmData.userId,
|
await sendDMViaWebSocket(
|
||||||
this.dmData.publicKey,
|
this.dmData.userId,
|
||||||
json,
|
json,
|
||||||
this.currentUser.authToken
|
this.currentUser.authToken
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await api.chats.dm.sendWithFiles(
|
await sendDmWithFiles(
|
||||||
this.dmData.userId,
|
this.dmData.userId,
|
||||||
this.dmData.publicKey,
|
this.dmData.publicKey,
|
||||||
json,
|
json,
|
||||||
files,
|
files,
|
||||||
this.currentUser.authToken
|
this.currentUser.authToken
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send DM:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,14 +263,14 @@ export class DMPanel extends MessagePanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (response.type === "dmEdited" && this.dmData) {
|
if (response.type === "dmEdited" && this.dmData) {
|
||||||
const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
|
const { id, senderId, recipientId, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
|
||||||
try {
|
try {
|
||||||
// Decrypt new content in-place
|
// Decrypt new content in-place
|
||||||
const plaintext = await api.chats.dm.decrypt(
|
const plaintext = await decryptDm(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
senderId: 0,
|
senderId,
|
||||||
recipientId: 0,
|
recipientId,
|
||||||
iv,
|
iv,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
salt,
|
salt,
|
||||||
@@ -274,7 +278,7 @@ export class DMPanel extends MessagePanel {
|
|||||||
wrappedMk,
|
wrappedMk,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
},
|
},
|
||||||
this.dmData.publicKey
|
senderId
|
||||||
);
|
);
|
||||||
let content = plaintext;
|
let content = plaintext;
|
||||||
let files: Message["files"] | undefined = undefined;
|
let files: Message["files"] | undefined = undefined;
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* Signal Protocol service wrapper
|
||||||
|
* Provides high-level API for encrypting/decrypting messages using Signal Protocol
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
SessionBuilder,
|
||||||
|
SessionCipher,
|
||||||
|
KeyHelper,
|
||||||
|
SignalProtocolAddress,
|
||||||
|
type DeviceType
|
||||||
|
} from "@privacyresearch/libsignal-protocol-typescript";
|
||||||
|
import { SignalProtocolStorage } from "./signalStorage";
|
||||||
|
import { b64, ub64 } from "../utils";
|
||||||
|
|
||||||
|
// Helper to ensure we get a proper ArrayBuffer (not SharedArrayBuffer)
|
||||||
|
function toArrayBuffer(buffer: ArrayBuffer | SharedArrayBuffer): ArrayBuffer {
|
||||||
|
if (buffer instanceof ArrayBuffer) return buffer;
|
||||||
|
// Convert SharedArrayBuffer to ArrayBuffer by copying
|
||||||
|
const view = new Uint8Array(buffer);
|
||||||
|
const copy = new Uint8Array(view.length);
|
||||||
|
copy.set(view);
|
||||||
|
return copy.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreKeyBundleData {
|
||||||
|
registrationId: number;
|
||||||
|
identityKey: string; // base64
|
||||||
|
signedPreKey: {
|
||||||
|
keyId: number;
|
||||||
|
publicKey: string; // base64
|
||||||
|
signature: string; // base64
|
||||||
|
};
|
||||||
|
preKey?: {
|
||||||
|
keyId: number;
|
||||||
|
publicKey: string; // base64
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SignalProtocolService {
|
||||||
|
private storage: SignalProtocolStorage;
|
||||||
|
|
||||||
|
constructor(userId: string) {
|
||||||
|
this.storage = new SignalProtocolStorage(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Signal Protocol for this user
|
||||||
|
* Generates identity keys, registration ID, and prekeys if they don't exist
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
// Check if already initialized
|
||||||
|
const existingIdentity = await this.storage.getIdentityKeyPair();
|
||||||
|
if (existingIdentity) {
|
||||||
|
return; // Already initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate identity key pair
|
||||||
|
const identityKeyPair = await KeyHelper.generateIdentityKeyPair();
|
||||||
|
await this.storage.saveIdentityKeyPair(identityKeyPair);
|
||||||
|
|
||||||
|
// Generate registration ID
|
||||||
|
const registrationId = KeyHelper.generateRegistrationId();
|
||||||
|
await this.storage.saveLocalRegistrationId(registrationId);
|
||||||
|
|
||||||
|
// Generate signed prekey
|
||||||
|
const signedPreKeyId = 1;
|
||||||
|
const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, signedPreKeyId);
|
||||||
|
await this.storage.storeSignedPreKey(signedPreKeyId, signedPreKey.keyPair);
|
||||||
|
|
||||||
|
// Store signature separately (we'll need it for the bundle)
|
||||||
|
// For now, we'll regenerate it when needed since storage doesn't store signatures
|
||||||
|
|
||||||
|
// Generate prekeys (typically 100 prekeys)
|
||||||
|
const preKeyCount = 100;
|
||||||
|
for (let i = 1; i <= preKeyCount; i++) {
|
||||||
|
const preKey = await KeyHelper.generatePreKey(i);
|
||||||
|
await this.storage.storePreKey(i, preKey.keyPair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get prekey bundle for this user to share with others
|
||||||
|
*/
|
||||||
|
async getPreKeyBundle(): Promise<PreKeyBundleData> {
|
||||||
|
const identityKeyPair = await this.storage.getIdentityKeyPair();
|
||||||
|
if (!identityKeyPair) {
|
||||||
|
throw new Error("Signal Protocol not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrationId = await this.storage.getLocalRegistrationId();
|
||||||
|
if (!registrationId) {
|
||||||
|
throw new Error("Registration ID not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedPreKey = await this.storage.loadSignedPreKey(1);
|
||||||
|
if (!signedPreKey) {
|
||||||
|
throw new Error("Signed prekey not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate signed prekey to get signature (since storage doesn't store it)
|
||||||
|
// In production, you'd store the signature separately
|
||||||
|
const signedPreKeyWithSig = await KeyHelper.generateSignedPreKey(identityKeyPair, 1);
|
||||||
|
await this.storage.storeSignedPreKey(1, signedPreKeyWithSig.keyPair);
|
||||||
|
|
||||||
|
// Get a prekey to include
|
||||||
|
const preKey = await this.storage.loadPreKey(1);
|
||||||
|
if (!preKey) {
|
||||||
|
throw new Error("No prekeys available");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
registrationId: registrationId,
|
||||||
|
identityKey: b64(new Uint8Array(identityKeyPair.pubKey)),
|
||||||
|
signedPreKey: {
|
||||||
|
keyId: 1,
|
||||||
|
publicKey: b64(new Uint8Array(signedPreKey.pubKey)),
|
||||||
|
signature: b64(new Uint8Array(signedPreKeyWithSig.signature))
|
||||||
|
},
|
||||||
|
preKey: {
|
||||||
|
keyId: 1,
|
||||||
|
publicKey: b64(new Uint8Array(preKey.pubKey))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a prekey bundle from another user and establish a session
|
||||||
|
*/
|
||||||
|
async processPreKeyBundle(recipientId: number, bundle: PreKeyBundleData): Promise<void> {
|
||||||
|
const address = new SignalProtocolAddress(recipientId.toString(), 1);
|
||||||
|
|
||||||
|
const identityKeyBuf = ub64(bundle.identityKey);
|
||||||
|
const signedPreKeyPubBuf = ub64(bundle.signedPreKey.publicKey);
|
||||||
|
const signedPreKeySigBuf = ub64(bundle.signedPreKey.signature);
|
||||||
|
|
||||||
|
const deviceBundle: DeviceType = {
|
||||||
|
identityKey: toArrayBuffer(identityKeyBuf.buffer.slice(identityKeyBuf.byteOffset, identityKeyBuf.byteOffset + identityKeyBuf.byteLength)),
|
||||||
|
signedPreKey: {
|
||||||
|
keyId: bundle.signedPreKey.keyId,
|
||||||
|
publicKey: toArrayBuffer(signedPreKeyPubBuf.buffer.slice(signedPreKeyPubBuf.byteOffset, signedPreKeyPubBuf.byteOffset + signedPreKeyPubBuf.byteLength)),
|
||||||
|
signature: toArrayBuffer(signedPreKeySigBuf.buffer.slice(signedPreKeySigBuf.byteOffset, signedPreKeySigBuf.byteOffset + signedPreKeySigBuf.byteLength))
|
||||||
|
},
|
||||||
|
preKey: bundle.preKey ? {
|
||||||
|
keyId: bundle.preKey.keyId,
|
||||||
|
publicKey: (() => {
|
||||||
|
const preKeyBuf = ub64(bundle.preKey!.publicKey);
|
||||||
|
return toArrayBuffer(preKeyBuf.buffer.slice(preKeyBuf.byteOffset, preKeyBuf.byteOffset + preKeyBuf.byteLength));
|
||||||
|
})()
|
||||||
|
} : undefined,
|
||||||
|
registrationId: bundle.registrationId
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionBuilder = new SessionBuilder(this.storage, address);
|
||||||
|
await sessionBuilder.processPreKey(deviceBundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a message for a recipient
|
||||||
|
*/
|
||||||
|
async encryptMessage(recipientId: number, plaintext: string): Promise<{ type: number; body: string }> {
|
||||||
|
const address = new SignalProtocolAddress(recipientId.toString(), 1);
|
||||||
|
|
||||||
|
const sessionCipher = new SessionCipher(this.storage, address);
|
||||||
|
const plaintextBuffer = toArrayBuffer(new TextEncoder().encode(plaintext).buffer);
|
||||||
|
const { type, body } = await sessionCipher.encrypt(plaintextBuffer);
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
throw new Error("Encryption failed: no body in ciphertext");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ciphertext.body is a base64 string, but we need to convert it properly
|
||||||
|
// According to the library, body is a serialized protobuf message as base64 string
|
||||||
|
return { type, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a message from a sender
|
||||||
|
*/
|
||||||
|
async decryptMessage(senderId: number, ciphertext: { type: number; body: string }): Promise<string> {
|
||||||
|
const address = new SignalProtocolAddress(senderId.toString(), 1);
|
||||||
|
|
||||||
|
const sessionCipher = new SessionCipher(this.storage, address);
|
||||||
|
|
||||||
|
// Handle both PreKeyWhisperMessage (type 3) and WhisperMessage (type 1)
|
||||||
|
const { buffer, byteOffset, byteLength } = ub64(ciphertext.body);
|
||||||
|
const bodyBuffer = toArrayBuffer(buffer.slice(byteOffset, byteOffset + byteLength));
|
||||||
|
let plaintextBytes: ArrayBuffer;
|
||||||
|
|
||||||
|
if (ciphertext.type === 3) {
|
||||||
|
// PreKeyWhisperMessage
|
||||||
|
plaintextBytes = await sessionCipher.decryptPreKeyWhisperMessage(bodyBuffer);
|
||||||
|
} else {
|
||||||
|
// WhisperMessage
|
||||||
|
plaintextBytes = await sessionCipher.decryptWhisperMessage(bodyBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextDecoder().decode(plaintextBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a session exists for a recipient
|
||||||
|
*/
|
||||||
|
async hasSession(recipientId: number): Promise<boolean> {
|
||||||
|
const address = new SignalProtocolAddress(recipientId.toString(), 1);
|
||||||
|
const sessionCipher = new SessionCipher(this.storage, address);
|
||||||
|
return await sessionCipher.hasOpenSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
/**
|
||||||
|
* IndexedDB storage implementation for Signal Protocol
|
||||||
|
* Stores identity keys, prekeys, signed prekeys, and session states
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { StorageType, KeyPairType, Direction } from "@privacyresearch/libsignal-protocol-typescript";
|
||||||
|
|
||||||
|
const DB_NAME = "signal_protocol_db";
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
|
||||||
|
interface SignalDB {
|
||||||
|
identityKeys: IDBObjectStore;
|
||||||
|
preKeys: IDBObjectStore;
|
||||||
|
signedPreKeys: IDBObjectStore;
|
||||||
|
sessions: IDBObjectStore;
|
||||||
|
registrationId: IDBObjectStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
if (dbPromise) return dbPromise;
|
||||||
|
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
|
||||||
|
// Identity keys store: key = userId, value = { publicKey, privateKey }
|
||||||
|
if (!db.objectStoreNames.contains("identityKeys")) {
|
||||||
|
db.createObjectStore("identityKeys", { keyPath: "userId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prekeys store: key = userId + preKeyId, value = { userId, preKeyId, publicKey, privateKey }
|
||||||
|
if (!db.objectStoreNames.contains("preKeys")) {
|
||||||
|
const preKeysStore = db.createObjectStore("preKeys", { keyPath: ["userId", "preKeyId"] });
|
||||||
|
preKeysStore.createIndex("userId", "userId", { unique: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed prekeys store: key = userId, value = { userId, keyId, publicKey, privateKey, signature }
|
||||||
|
if (!db.objectStoreNames.contains("signedPreKeys")) {
|
||||||
|
db.createObjectStore("signedPreKeys", { keyPath: "userId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions store: key = userId + deviceId, value = { userId, deviceId, record }
|
||||||
|
if (!db.objectStoreNames.contains("sessions")) {
|
||||||
|
const sessionsStore = db.createObjectStore("sessions", { keyPath: ["userId", "deviceId"] });
|
||||||
|
sessionsStore.createIndex("userId", "userId", { unique: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registration ID store: key = userId, value = { userId, registrationId }
|
||||||
|
if (!db.objectStoreNames.contains("registrationId")) {
|
||||||
|
db.createObjectStore("registrationId", { keyPath: "userId" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStore(storeName: keyof SignalDB, mode: IDBTransactionMode = "readonly"): Promise<IDBObjectStore> {
|
||||||
|
const db = await openDB();
|
||||||
|
const tx = db.transaction([storeName], mode);
|
||||||
|
return tx.objectStore(storeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert Uint8Array to ArrayBuffer
|
||||||
|
function toArrayBuffer(u8: Uint8Array | ArrayBuffer): ArrayBuffer {
|
||||||
|
if (u8 instanceof ArrayBuffer) return u8;
|
||||||
|
return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert ArrayBuffer to Uint8Array
|
||||||
|
function toUint8Array(ab: ArrayBuffer | Uint8Array): Uint8Array {
|
||||||
|
if (ab instanceof Uint8Array) return ab;
|
||||||
|
return new Uint8Array(ab);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SignalProtocolStorage implements StorageType {
|
||||||
|
private userId: string;
|
||||||
|
|
||||||
|
constructor(userId: string) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity Key Management
|
||||||
|
async getIdentityKeyPair(): Promise<KeyPairType | undefined> {
|
||||||
|
const store = await getStore("identityKeys");
|
||||||
|
const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer } | undefined>((resolve, reject) => {
|
||||||
|
const request = store.get(this.userId);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result;
|
||||||
|
if (!data) {
|
||||||
|
resolve(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
pubKey: toArrayBuffer(data.publicKey),
|
||||||
|
privKey: toArrayBuffer(data.privateKey)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocalRegistrationId(): Promise<number | undefined> {
|
||||||
|
const store = await getStore("registrationId");
|
||||||
|
const result = await new Promise<{ registrationId: number } | undefined>((resolve, reject) => {
|
||||||
|
const request = store.get(this.userId);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result;
|
||||||
|
resolve(data ? { registrationId: data.registrationId } : undefined);
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result?.registrationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isTrustedIdentity(identifier: string, identityKey: ArrayBuffer, direction: Direction): Promise<boolean> {
|
||||||
|
// For now, always trust (can be enhanced with key verification)
|
||||||
|
// In production, you'd check against previously stored identity keys
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveIdentity(encodedAddress: string, publicKey: ArrayBuffer, nonblockingApproval?: boolean): Promise<boolean> {
|
||||||
|
// Store other users' identity keys if needed
|
||||||
|
// For now, we trust all identities
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for initialization (not part of StorageType interface)
|
||||||
|
async saveIdentityKeyPair(keyPair: KeyPairType): Promise<void> {
|
||||||
|
const store = await getStore("identityKeys", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.put({
|
||||||
|
userId: this.userId,
|
||||||
|
publicKey: toUint8Array(keyPair.pubKey),
|
||||||
|
privateKey: toUint8Array(keyPair.privKey)
|
||||||
|
});
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveLocalRegistrationId(registrationId: number): Promise<void> {
|
||||||
|
const store = await getStore("registrationId", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.put({
|
||||||
|
userId: this.userId,
|
||||||
|
registrationId: registrationId
|
||||||
|
});
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreKey Management
|
||||||
|
async loadPreKey(encodedAddress: string | number): Promise<KeyPairType | undefined> {
|
||||||
|
const preKeyId = typeof encodedAddress === "number" ? encodedAddress : parseInt(encodedAddress, 10);
|
||||||
|
const store = await getStore("preKeys");
|
||||||
|
const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer } | undefined>((resolve, reject) => {
|
||||||
|
const request = store.get([this.userId, preKeyId]);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result;
|
||||||
|
if (!data) {
|
||||||
|
resolve(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
pubKey: toArrayBuffer(data.publicKey),
|
||||||
|
privKey: toArrayBuffer(data.privateKey)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async storePreKey(keyId: number | string, keyPair: KeyPairType): Promise<void> {
|
||||||
|
const preKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||||
|
const store = await getStore("preKeys", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.put({
|
||||||
|
userId: this.userId,
|
||||||
|
preKeyId: preKeyId,
|
||||||
|
publicKey: toUint8Array(keyPair.pubKey),
|
||||||
|
privateKey: toUint8Array(keyPair.privKey)
|
||||||
|
});
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePreKey(keyId: number | string): Promise<void> {
|
||||||
|
const preKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||||
|
const store = await getStore("preKeys", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.delete([this.userId, preKeyId]);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed PreKey Management
|
||||||
|
async loadSignedPreKey(keyId: number | string): Promise<KeyPairType | undefined> {
|
||||||
|
const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||||
|
const store = await getStore("signedPreKeys");
|
||||||
|
const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer; keyId: number } | undefined>((resolve, reject) => {
|
||||||
|
const request = store.get(this.userId);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result;
|
||||||
|
if (!data || data.keyId !== signedPreKeyId) {
|
||||||
|
resolve(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
pubKey: toArrayBuffer(data.publicKey),
|
||||||
|
privKey: toArrayBuffer(data.privateKey)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeSignedPreKey(keyId: number | string, keyPair: KeyPairType): Promise<void> {
|
||||||
|
const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||||
|
const store = await getStore("signedPreKeys", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.put({
|
||||||
|
userId: this.userId,
|
||||||
|
keyId: signedPreKeyId,
|
||||||
|
publicKey: toUint8Array(keyPair.pubKey),
|
||||||
|
privateKey: toUint8Array(keyPair.privKey)
|
||||||
|
});
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSignedPreKey(keyId: number | string): Promise<void> {
|
||||||
|
const store = await getStore("signedPreKeys", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.delete(this.userId);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session Management
|
||||||
|
async loadSession(encodedAddress: string): Promise<string | undefined> {
|
||||||
|
// encodedAddress format: "userId.deviceId"
|
||||||
|
const parts = encodedAddress.split(".");
|
||||||
|
const deviceId = parts.length > 1 ? parts[1] : encodedAddress;
|
||||||
|
|
||||||
|
const store = await getStore("sessions");
|
||||||
|
const result = await new Promise<{ record: string } | undefined>((resolve, reject) => {
|
||||||
|
const request = store.get([this.userId, deviceId]);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result;
|
||||||
|
resolve(data ? data.record : undefined);
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeSession(encodedAddress: string, record: string): Promise<void> {
|
||||||
|
// encodedAddress format: "userId.deviceId"
|
||||||
|
const parts = encodedAddress.split(".");
|
||||||
|
const deviceId = parts.length > 1 ? parts[1] : encodedAddress;
|
||||||
|
|
||||||
|
const store = await getStore("sessions", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.put({
|
||||||
|
userId: this.userId,
|
||||||
|
deviceId: deviceId,
|
||||||
|
record: record
|
||||||
|
});
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
"vite-plugin-sass-dts": "^1.3.34"
|
"vite-plugin-sass-dts": "^1.3.34"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@privacyresearch/libsignal-protocol-typescript": "^0.0.16",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"escape-string-regexp": "^5.0.0",
|
"escape-string-regexp": "^5.0.0",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user