mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Restructure backend into microservices, add envelope encryption, DM files, and message editing
This commit is contained in:
@@ -2,18 +2,83 @@ import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import { getCurrentKeys } from "../user/auth";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, BaseDmEnvelope, User } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import type { DmEnvelope, User } from "@/core/types";
|
||||
import { ub64 } from "@/utils/utils";
|
||||
import { fetchUserPublicKey } from "../crypto/identity";
|
||||
import { fetchUsers, searchUsers } from "../user/search";
|
||||
import { getOrInitProtocol } from "@/utils/crypto/fromchatInit";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, randomBytes } from "@fromchat/protocol";
|
||||
import { deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
|
||||
import tweetnacl from "tweetnacl";
|
||||
|
||||
export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const protocol = getOrInitProtocol();
|
||||
const senderPublicKey = ub64(senderPublicKeyB64);
|
||||
|
||||
return await protocol.decryptMessage(senderPublicKey, envelope);
|
||||
/**
|
||||
* Unwrap a MEK using the appropriate wrapping key for the current user
|
||||
*/
|
||||
export async function unwrapMek(wrappedMekB64: string, envelope: DmEnvelope, userId?: number): Promise<Uint8Array> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Determine context based on whether we're sender or recipient
|
||||
const currentUserId = userId || parseInt(localStorage.getItem('userId') || '0');
|
||||
const isRecipient = envelope.recipientId === currentUserId;
|
||||
const context = isRecipient ? "recipient_wrap_key" : "sender_wrap_key";
|
||||
|
||||
// Derive wrapping key from our public key
|
||||
const salt = new Uint8Array(16).fill(0); // 16 zero bytes salt
|
||||
const wrappingKeyRaw = await deriveWrappingKey(keys.publicKey, salt, new TextEncoder().encode(context));
|
||||
const wrappingKey = await importAesGcmKey(wrappingKeyRaw);
|
||||
|
||||
// Unwrap the MEK using AES-256-GCM
|
||||
const wrappedMekBytes = ub64(wrappedMekB64);
|
||||
const mekNonce = wrappedMekBytes.slice(0, 12);
|
||||
const mekCiphertext = wrappedMekBytes.slice(12);
|
||||
|
||||
return await aesGcmDecrypt(wrappingKey, mekNonce, mekCiphertext);
|
||||
}
|
||||
|
||||
export async function decrypt(envelope: DmEnvelope, userId?: number): Promise<string> {
|
||||
try {
|
||||
// Use the wrapped MEK provided for this user
|
||||
const wrappedMekB64 = envelope.wrapped_mek_b64;
|
||||
if (!wrappedMekB64) throw new Error("No wrapped MEK available for decryption");
|
||||
|
||||
console.log("🔐 Decrypting DM envelope:", {
|
||||
id: envelope.id,
|
||||
senderId: envelope.senderId,
|
||||
recipientId: envelope.recipientId,
|
||||
hasWrappedMek: !!wrappedMekB64,
|
||||
wrappedMekLength: wrappedMekB64?.length
|
||||
});
|
||||
|
||||
// Unwrap the MEK using shared logic
|
||||
const mek = await unwrapMek(wrappedMekB64, envelope, userId);
|
||||
|
||||
console.log("🔓 MEK unwrapped successfully, length:", mek.length);
|
||||
|
||||
// Decrypt the message using the unwrapped MEK
|
||||
// Server encrypts with AES-GCM, so client decrypts with AES-GCM
|
||||
// envelope.iv_b64 and envelope.ciphertext_b64 are base64-encoded separately
|
||||
const messageKey = await importAesGcmKey(mek);
|
||||
const messageNonce = ub64(envelope.iv_b64 || "");
|
||||
const messageCiphertext = ub64(envelope.ciphertext_b64);
|
||||
|
||||
console.log("💬 Message decryption with AES-GCM:", {
|
||||
ivLength: messageNonce.length,
|
||||
ciphertextLength: messageCiphertext.length
|
||||
});
|
||||
|
||||
const plaintext = await aesGcmDecrypt(messageKey, messageNonce, messageCiphertext);
|
||||
const result = new TextDecoder().decode(plaintext);
|
||||
|
||||
console.log("✅ Decryption successful:", result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to decrypt DM envelope:", error);
|
||||
console.error("Error details:", {
|
||||
envelope: envelope,
|
||||
userId: userId,
|
||||
localStorageUserId: localStorage.getItem('userId')
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
||||
@@ -29,92 +94,162 @@ export async function fetchMessages(userId: number, token: string, limit: number
|
||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||
}
|
||||
|
||||
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const protocol = getOrInitProtocol();
|
||||
const recipientPublicKey = ub64(recipientPublicKeyB64);
|
||||
|
||||
const encrypted = await protocol.encryptMessage(recipientPublicKey, plaintext);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
...encrypted
|
||||
};
|
||||
if (replyToId) payload.replyToId = replyToId;
|
||||
|
||||
await request({
|
||||
type: "dmSend",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
/**
|
||||
* Get the transport public key from the server
|
||||
*/
|
||||
async function getTransportPublicKey(): Promise<string> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch transport key: HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
return data.public_key_b64;
|
||||
}
|
||||
|
||||
export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
// For files, we need to use the same message key for both the message and files
|
||||
// So we'll do the encryption manually here to reuse the mk
|
||||
/**
|
||||
* Encrypt message using transport key (client-side only)
|
||||
*/
|
||||
function encryptWithTransportKey(plaintext: string, transportPublicKeyB64: string): { client_public_key_b64: string; nonce_b64: string; ciphertext_b64: string } {
|
||||
const plaintextBytes = new TextEncoder().encode(plaintext);
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
const transportPublicKeyBytes = new Uint8Array(
|
||||
atob(transportPublicKeyB64)
|
||||
.split("")
|
||||
.map((c: string) => c.charCodeAt(0))
|
||||
);
|
||||
|
||||
const nonce = tweetnacl.randomBytes(24);
|
||||
const ciphertext = tweetnacl.box(plaintextBytes, nonce, transportPublicKeyBytes, ephemeralKeypair.secretKey);
|
||||
|
||||
return {
|
||||
client_public_key_b64: btoa(String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])),
|
||||
nonce_b64: btoa(String.fromCharCode.apply(null, Array.from(nonce) as number[])),
|
||||
ciphertext_b64: btoa(String.fromCharCode.apply(null, Array.from(ciphertext) as number[]))
|
||||
};
|
||||
}
|
||||
|
||||
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number, attachments?: Array<{name:string,path:string,wrapped_mek_b64?:string,nonce_b64?:string}>): Promise<void> {
|
||||
// Get keys
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||
|
||||
// Client-side transport encryption only
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||
|
||||
// Get sender's public key (from current keys)
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Send to server (server will handle envelope encryption)
|
||||
const bodyPayload: any = {
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64,
|
||||
reply_to_id: replyToId
|
||||
};
|
||||
if (attachments && attachments.length > 0) bodyPayload["files"] = attachments;
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify(bodyPayload)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
export async function sendWithFiles(
|
||||
recipientId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
files: File[],
|
||||
plaintext: string,
|
||||
authToken: string,
|
||||
replyToId?: number
|
||||
): Promise<void> {
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error("No files provided");
|
||||
}
|
||||
|
||||
// Get transport key for encryption (shared across message + files)
|
||||
const transportKeyResponse = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||
if (!transportKeyResponse.ok) {
|
||||
throw new Error("Failed to get transport key");
|
||||
}
|
||||
const transportKeyData = await transportKeyResponse.json();
|
||||
const transportPublicKeyB64 = transportKeyData.public_key_b64;
|
||||
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
const transportPublicKey = ub64(transportPublicKeyB64);
|
||||
|
||||
const form = new FormData();
|
||||
const names: string[] = [];
|
||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||
}
|
||||
// Transport-encrypt message (client-side transport only; server will envelope-encrypt)
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext || "", transportPublicKeyB64);
|
||||
|
||||
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;
|
||||
names.push(serverName);
|
||||
form.append("files", new File([blob], serverName));
|
||||
}
|
||||
form.append("fileNames", JSON.stringify(names));
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Encrypt the plaintext JSON with the same mk
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintextJson));
|
||||
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 globalThis.fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, false),
|
||||
body: form
|
||||
});
|
||||
}
|
||||
|
||||
export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const protocol = getOrInitProtocol();
|
||||
const recipientPublicKey = ub64(recipientPublicKeyB64);
|
||||
|
||||
const encrypted = await protocol.encryptMessage(recipientPublicKey, newPlaintextJson);
|
||||
|
||||
await request({
|
||||
type: "dmEdit",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: {
|
||||
id,
|
||||
...encrypted
|
||||
// Base64 encode helper (chunked)
|
||||
const uint8ToB64 = (uint8: Uint8Array): string => {
|
||||
const CHUNK = 0x8000;
|
||||
let binary = "";
|
||||
for (let i = 0; i < uint8.length; i += CHUNK) {
|
||||
binary += String.fromCharCode.apply(null, Array.from(uint8.subarray(i, i + CHUNK)) as number[]);
|
||||
}
|
||||
} as DMEditRequest);
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
// Transport-encrypt files; server will envelope-encrypt them with the SAME MEK as the message.
|
||||
const transport_files: Array<{ encrypted_file_data_b64: string; filename: string; file_size: number }> = [];
|
||||
for (const file of files) {
|
||||
const fileData = await file.arrayBuffer();
|
||||
const transportNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||
const transportEncrypted = tweetnacl.box(
|
||||
new Uint8Array(fileData),
|
||||
transportNonce,
|
||||
transportPublicKey,
|
||||
keys.privateKey
|
||||
);
|
||||
const transportEncryptedWithNonce = new Uint8Array(transportNonce.length + transportEncrypted.length);
|
||||
transportEncryptedWithNonce.set(transportNonce);
|
||||
transportEncryptedWithNonce.set(transportEncrypted, transportNonce.length);
|
||||
|
||||
transport_files.push({
|
||||
encrypted_file_data_b64: uint8ToB64(transportEncryptedWithNonce),
|
||||
filename: file.name,
|
||||
file_size: file.size
|
||||
});
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64,
|
||||
reply_to_id: replyToId,
|
||||
transport_files
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
@@ -149,5 +284,43 @@ export async function markRead(id: number, authToken: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMessage(
|
||||
messageId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
plaintext: string,
|
||||
authToken: string
|
||||
): Promise<void> {
|
||||
// Get keys
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Get transport key for initial encryption
|
||||
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||
|
||||
// Client-side transport encryption (same as sending)
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||
|
||||
// Get sender's public key
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Send transport-encrypted data to the edit endpoint (it will handle envelope encryption)
|
||||
const editResponse = await fetch(`${API_BASE_URL}/dm/edit/${messageId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64
|
||||
})
|
||||
});
|
||||
|
||||
if (!editResponse.ok) throw new Error(`Failed to edit DM: HTTP ${editResponse.status}`);
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
+190
-129
@@ -1,26 +1,18 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
|
||||
import { getCurrentKeys } from "./account";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import type { DmEnvelope, User } from "@/core/types";
|
||||
import { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
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);
|
||||
/**
|
||||
* Decrypt a DM envelope using client-side MEK unwrapping.
|
||||
* This delegates to the chats/dm module which has the updated implementation.
|
||||
*/
|
||||
export async function decryptDm(envelope: DmEnvelope): Promise<string> {
|
||||
// Import and use the updated implementation from chats/dm
|
||||
const { decrypt } = await import("./chats/dm");
|
||||
return decrypt(envelope);
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
@@ -35,121 +27,16 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
/**
|
||||
* Send DM via WebSocket using transport encryption.
|
||||
* This delegates to the HTTP endpoint which handles envelope encryption on server.
|
||||
*/
|
||||
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
|
||||
});
|
||||
// Import and use the updated implementation from chats/dm
|
||||
const { send } = await import("./chats/dm");
|
||||
return send(recipientId, recipientPublicKeyB64, plaintext, authToken, replyToId);
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -174,3 +61,177 @@ export async function fetchDMConversations(token: string): Promise<DMConversatio
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Envelope Encryption (Private DMs with compliance support)
|
||||
// ============================================================================
|
||||
|
||||
interface TransportKey {
|
||||
key_id: string;
|
||||
public_key_b64: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface TransportEncryptedMessage {
|
||||
client_public_key_b64: string;
|
||||
nonce_b64: string;
|
||||
ciphertext_b64: string;
|
||||
}
|
||||
|
||||
let cachedTransportKey: TransportKey | null = null;
|
||||
|
||||
/**
|
||||
* Fetch current transport public key from messaging service.
|
||||
* Caches result with validation.
|
||||
*/
|
||||
export async function getTransportPublicKey(): Promise<TransportKey> {
|
||||
if (cachedTransportKey) {
|
||||
return cachedTransportKey;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/dm/key/transport/public`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const data: TransportKey = await response.json();
|
||||
cachedTransportKey = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch transport public key:", error);
|
||||
}
|
||||
|
||||
throw new Error("Failed to fetch transport public key");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message using the transport public key (X25519 + ChaCha20).
|
||||
*/
|
||||
function encryptMessageWithTransportKey(
|
||||
plaintext: string | Uint8Array,
|
||||
transportPublicKeyB64: string
|
||||
): { nonce_b64: string; ciphertext_b64: string; client_public_key_b64: string } {
|
||||
const tweetnacl = require("tweetnacl");
|
||||
|
||||
// Convert plaintext to bytes if string
|
||||
const plaintextBytes = typeof plaintext === "string" ? new TextEncoder().encode(plaintext) : plaintext;
|
||||
|
||||
// Generate ephemeral keypair for this message
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
|
||||
// Decode transport public key
|
||||
const transportPublicKeyBytes = new Uint8Array(
|
||||
atob(transportPublicKeyB64)
|
||||
.split("")
|
||||
.map((c: string) => c.charCodeAt(0))
|
||||
);
|
||||
|
||||
// Perform ECDH (shared secret via tweetnacl's box)
|
||||
const nonce = tweetnacl.randomBytes(24);
|
||||
const ciphertext = tweetnacl.box(plaintextBytes, nonce, transportPublicKeyBytes, ephemeralKeypair.secretKey);
|
||||
|
||||
// Encode to base64
|
||||
const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(nonce) as number[]));
|
||||
const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(ciphertext) as number[]));
|
||||
const client_public_key_b64 = btoa(
|
||||
String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])
|
||||
);
|
||||
|
||||
return { nonce_b64, ciphertext_b64, client_public_key_b64 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext with transport public key for sending to server.
|
||||
* Server will handle envelope encryption (MEK generation and wrapping).
|
||||
*/
|
||||
export async function encryptMessageForTransport(plaintext: string): Promise<TransportEncryptedMessage> {
|
||||
const transportKey = await getTransportPublicKey();
|
||||
return encryptMessageWithTransportKey(plaintext, transportKey.public_key_b64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an encrypted DM message using envelope encryption.
|
||||
* Client encrypts with transport key, server handles envelope encryption.
|
||||
*/
|
||||
export async function sendEncryptedDM(
|
||||
recipientId: number,
|
||||
plaintext: string,
|
||||
token: string,
|
||||
replyToId?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Client-side transport encryption
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } =
|
||||
await encryptMessageForTransport(plaintext);
|
||||
|
||||
// Send to server
|
||||
const response = await fetch(`${API_BASE_URL}/api/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(token, true)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
reply_to_id: replyToId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to send encrypted DM:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encrypted conversation history with another user.
|
||||
*/
|
||||
export async function getEncryptedConversation(
|
||||
otherUserId: number,
|
||||
token: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const url = new URL(`${API_BASE_URL}/api/dm/conversation/${otherUserId}`);
|
||||
url.searchParams.append("limit", String(limit));
|
||||
url.searchParams.append("offset", String(offset));
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch encrypted conversation with user ${otherUserId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an encrypted message.
|
||||
*/
|
||||
export async function deleteEncryptedDM(messageId: number, token: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/dm/${messageId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete encrypted DM ${messageId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached keys (useful on logout).
|
||||
*/
|
||||
export function clearCachedKeys(): void {
|
||||
cachedTransportKey = null;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
|
||||
import { getCurrentKeys } from "./account";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import type { DmEnvelope, User } from "@/core/types";
|
||||
import { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
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 decryptDm(envelope: DmEnvelope): Promise<string> {
|
||||
const { decrypt } = await import("./chats/dm");
|
||||
return decrypt(envelope);
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
@@ -36,120 +23,10 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe
|
||||
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
|
||||
});
|
||||
const { send } = await import("./chats/dm");
|
||||
return send(recipientId, recipientPublicKeyB64, plaintext, authToken, replyToId);
|
||||
}
|
||||
|
||||
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({
|
||||
|
||||
@@ -58,25 +58,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
|
||||
|
||||
Vendored
+12
-10
@@ -183,11 +183,9 @@ export interface UploadPublicKeyRequest {
|
||||
|
||||
export interface SendDMRequest {
|
||||
recipientId: number;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedMk: string;
|
||||
iv_b64: string;
|
||||
ciphertext_b64: string;
|
||||
wrapped_mek_b64: string;
|
||||
replyToId?: number;
|
||||
}
|
||||
|
||||
@@ -209,11 +207,9 @@ export interface BackupBlob {
|
||||
}
|
||||
|
||||
export interface BaseDmEnvelope {
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedMk: string;
|
||||
iv_b64: string;
|
||||
ciphertext_b64: string;
|
||||
wrapped_mek_b64: string;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
@@ -223,12 +219,16 @@ export interface DmEnvelope extends BaseDmEnvelope {
|
||||
files?: DmFile[];
|
||||
timestamp: string;
|
||||
reactions?: Reaction[];
|
||||
replyToId?: number;
|
||||
}
|
||||
|
||||
export interface DmFile {
|
||||
name: string;
|
||||
id: number;
|
||||
path: string;
|
||||
dm_envelope_id?: number;
|
||||
wrapped_mek_b64?: string;
|
||||
nonce_b64?: string;
|
||||
}
|
||||
|
||||
export interface DmEditedPayload {
|
||||
@@ -306,6 +306,8 @@ export interface Attachment {
|
||||
path: string;
|
||||
encrypted: boolean;
|
||||
name: string;
|
||||
wrapped_mek_b64?: string;
|
||||
nonce_b64?: string;
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
|
||||
@@ -64,7 +64,13 @@ export function useDM() {
|
||||
let lastPlaintext: string | null = null;
|
||||
|
||||
try {
|
||||
lastPlaintext = (JSON.parse(await api.chats.dm.decrypt(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
|
||||
const decrypted = await api.chats.dm.decrypt(lastMessage, user.currentUser?.id);
|
||||
try {
|
||||
lastPlaintext = (JSON.parse(decrypted) as DmEncryptedJSON).data.content;
|
||||
} catch {
|
||||
// Fallback: decrypted payload is plain text
|
||||
lastPlaintext = decrypted;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt last message:", error);
|
||||
}
|
||||
@@ -118,9 +124,14 @@ export function useDM() {
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
// Decrypt the last message
|
||||
const decryptedJson = await api.chats.dm.decrypt(conv.lastMessage, publicKey!);
|
||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||
lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!);
|
||||
const decryptedJson = await api.chats.dm.decrypt(conv.lastMessage, user.currentUser?.id);
|
||||
let messageText: string;
|
||||
try {
|
||||
messageText = (JSON.parse(decryptedJson) as DmEncryptedJSON).data.content;
|
||||
} catch {
|
||||
messageText = decryptedJson;
|
||||
}
|
||||
lastMessageContent = formatDMMessageContent(messageText, conv.lastMessage.senderId, user.currentUser?.id!);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt last message for user", conv.user.id, error);
|
||||
@@ -152,7 +163,7 @@ export function useDM() {
|
||||
}, [user.authToken]);
|
||||
|
||||
// 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;
|
||||
|
||||
setIsLoadingHistory(true);
|
||||
@@ -163,7 +174,7 @@ export function useDM() {
|
||||
|
||||
for (const env of messages) {
|
||||
try {
|
||||
const text = await api.chats.dm.decrypt(env, publicKey);
|
||||
const text = await api.chats.dm.decrypt(env, user.currentUser?.id);
|
||||
const isAuthor = env.senderId !== userId;
|
||||
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
|
||||
|
||||
@@ -234,7 +245,7 @@ export function useDM() {
|
||||
});
|
||||
|
||||
// Load conversation history
|
||||
await loadDMHistory(dmUser.id, publicKey);
|
||||
await loadDMHistory(dmUser.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to start DM conversation:", error);
|
||||
}
|
||||
@@ -267,9 +278,14 @@ export function useDM() {
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
// Decrypt the last message
|
||||
const decryptedJson = await api.chats.dm.decrypt(userConversation.lastMessage, publicKey!);
|
||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||
lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser?.id!);
|
||||
const decryptedJson = await api.chats.dm.decrypt(userConversation.lastMessage, user.currentUser?.id);
|
||||
let messageText: string;
|
||||
try {
|
||||
messageText = (JSON.parse(decryptedJson) as DmEncryptedJSON).data.content;
|
||||
} catch {
|
||||
messageText = decryptedJson;
|
||||
}
|
||||
lastMessageContent = formatDMMessageContent(messageText, userConversation.lastMessage.senderId, user.currentUser?.id!);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt last message for user", userId, error);
|
||||
@@ -318,7 +334,7 @@ export function useDM() {
|
||||
try {
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey);
|
||||
const decryptedJson = await api.chats.dm.decrypt(envelope, user.currentUser?.id);
|
||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||
const messageContent = decryptedData.data.content;
|
||||
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
|
||||
@@ -348,7 +364,7 @@ export function useDM() {
|
||||
try {
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey);
|
||||
const decryptedJson = await api.chats.dm.decrypt(envelope, user.currentUser?.id);
|
||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||
const messageContent = decryptedData.data.content;
|
||||
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
|
||||
|
||||
@@ -16,10 +16,9 @@ interface ChatMessagesProps {
|
||||
onEditSelect?: (message: MessageType) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
onRetryMessage?: (messageId: number) => void;
|
||||
dmRecipientPublicKey?: string;
|
||||
}
|
||||
|
||||
export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) {
|
||||
export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage }: ChatMessagesProps) {
|
||||
const { user } = useUserStore();
|
||||
|
||||
// Context menu state
|
||||
@@ -122,8 +121,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
}
|
||||
onContextMenu={handleContextMenu}
|
||||
onReactionClick={handleReactionClick}
|
||||
isDm={isDm}
|
||||
dmRecipientPublicKey={dmRecipientPublicKey} />
|
||||
isDm={isDm} />
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatTime, id } from "@/utils/utils";
|
||||
import { formatTime, id, ub64 } from "@/utils/utils";
|
||||
import type { Attachment, Message as MessageType, Reaction } from "@/core/types";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import Quote from "@/core/components/Quote";
|
||||
@@ -6,11 +6,10 @@ import { parse } from "marked";
|
||||
import { escape as escapeHtml } from "he";
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import api from "@/core/api";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
|
||||
import { importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { useProfileStore } from "@/state/profile";
|
||||
import { StatusBadge } from "@/core/components/StatusBadge";
|
||||
import { ub64 } from "@/utils/utils";
|
||||
import { useImmer } from "use-immer";
|
||||
import { createPortal } from "react-dom";
|
||||
import { parseProfileLink } from "@/core/profileLinks";
|
||||
@@ -139,7 +138,6 @@ interface MessageProps {
|
||||
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
|
||||
onReactionClick?: (messageId: number, emoji: string) => void;
|
||||
isDm?: boolean;
|
||||
dmRecipientPublicKey?: string;
|
||||
}
|
||||
|
||||
interface Rect {
|
||||
@@ -149,7 +147,7 @@ interface Rect {
|
||||
height: number
|
||||
}
|
||||
|
||||
export function Message({ message, isAuthor, onContextMenu, onReactionClick, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
||||
export function Message({ message, isAuthor, onContextMenu, onReactionClick, isDm = false }: MessageProps) {
|
||||
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
|
||||
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
|
||||
const [downloadingPaths, updateDownloadingPaths] = useImmer<Set<string>>(new Set());
|
||||
@@ -198,7 +196,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
if (isDm && message.files) {
|
||||
message.files.forEach(async (file) => {
|
||||
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
|
||||
if (isImage && file.encrypted && !decryptedFiles.has(file.path)) {
|
||||
const looksEncryptedPath = /\/uploads\/files\/encrypted\//.test(file.path) || /\/api\/uploads\/files\/encrypted\//.test(file.path);
|
||||
const shouldDecrypt = Boolean(file.encrypted || looksEncryptedPath);
|
||||
if (isImage && shouldDecrypt && !decryptedFiles.has(file.path)) {
|
||||
const decryptedUrl = await decryptFile(file);
|
||||
if (decryptedUrl) {
|
||||
updateDecryptedFiles(draft => {
|
||||
@@ -211,7 +211,14 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
}, [message.files, isDm, decryptedFiles]);
|
||||
|
||||
async function decryptFile(file: Attachment): Promise<string | null> {
|
||||
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null;
|
||||
if (!isDm || !user.authToken || !dmEnvelope) return null;
|
||||
|
||||
const userKeys = api.user.auth.getCurrentKeys();
|
||||
if (!userKeys) return null;
|
||||
|
||||
const looksEncryptedPath = /\/uploads\/files\/encrypted\//.test(file.path) || /\/api\/uploads\/files\/encrypted\//.test(file.path);
|
||||
const shouldDecrypt = Boolean(file.encrypted || looksEncryptedPath);
|
||||
if (!shouldDecrypt) return null;
|
||||
|
||||
// Check if already decrypted
|
||||
if (decryptedFiles.has(file.path)) {
|
||||
@@ -232,23 +239,44 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Derive shared secret with the recipient's public key
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey));
|
||||
// Decrypt file using the envelope encryption MEK unwrapping logic
|
||||
// Use the same logic as message decryption
|
||||
// Prefer file-specific wrapped MEK (attachments have their own wrapped MEK)
|
||||
|
||||
// Derive wrapping key using the salt from the DM envelope
|
||||
const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
// Get MEK from envelope file data - server provides user-specific MEK
|
||||
const envelopeFile = dmEnvelope.files?.find(f => f.path === file.path);
|
||||
const fileWrapped = file.wrapped_mek_b64;
|
||||
const envelopeWrapped = envelopeFile?.wrapped_mek_b64;
|
||||
const dmWrapped = dmEnvelope.wrapped_mek_b64;
|
||||
|
||||
// Unwrap the message key
|
||||
const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk));
|
||||
const wrappedMekB64 = fileWrapped || envelopeWrapped || dmWrapped;
|
||||
|
||||
// Decrypt the file using the message key
|
||||
const iv = new Uint8Array(encryptedData, 0, 12);
|
||||
const ciphertext = new Uint8Array(encryptedData, 12);
|
||||
if (!wrappedMekB64) {
|
||||
console.error("No MEK available for file decryption:", file.path);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unwrap the MEK using the same logic as message decryption
|
||||
const mk = await api.chats.dm.unwrapMek(wrappedMekB64, dmEnvelope, user.currentUser?.id);
|
||||
|
||||
// Decrypt the file using the unwrapped MEK
|
||||
const nonceB64 = file.nonce_b64 || envelopeFile?.nonce_b64;
|
||||
if (!nonceB64) throw new Error("No nonce available for file decryption");
|
||||
|
||||
const iv = ub64(nonceB64);
|
||||
const ciphertext = new Uint8Array(encryptedData);
|
||||
const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext);
|
||||
|
||||
// Create blob URL for download
|
||||
const blob = new Blob([decrypted.buffer as ArrayBuffer]);
|
||||
const ext = (file.name || "").toLowerCase().split(".").pop();
|
||||
const mime =
|
||||
ext === "png" ? "image/png" :
|
||||
ext === "jpg" || ext === "jpeg" ? "image/jpeg" :
|
||||
ext === "gif" ? "image/gif" :
|
||||
ext === "webp" ? "image/webp" :
|
||||
"application/octet-stream";
|
||||
const decryptedBuf = (decrypted.buffer as ArrayBuffer).slice(decrypted.byteOffset, decrypted.byteOffset + decrypted.byteLength);
|
||||
const blob = new Blob([decryptedBuf], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
updateDecryptedFiles(draft => {
|
||||
@@ -268,7 +296,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
const decryptedUrl = decryptedFiles.get(file.path);
|
||||
if (decryptedUrl) {
|
||||
openFullscreenFromThumb(imageElement, decryptedUrl, file.name || "image");
|
||||
} else if (file.encrypted && isDm) {
|
||||
} else if (isDm && (file.encrypted || /\/uploads\/files\/encrypted\//.test(file.path) || /\/api\/uploads\/files\/encrypted\//.test(file.path))) {
|
||||
const newDecryptedUrl = await decryptFile(file);
|
||||
if (newDecryptedUrl) {
|
||||
openFullscreenFromThumb(imageElement, newDecryptedUrl, file.name || "image");
|
||||
@@ -378,6 +406,22 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is an encrypted DM attachment, decrypt before downloading
|
||||
const looksEncryptedPath = /\/uploads\/files\/encrypted\//.test(file.path) || /\/api\/uploads\/files\/encrypted\//.test(file.path);
|
||||
if (isDm && (file.encrypted || looksEncryptedPath)) {
|
||||
const decryptedUrl = await decryptFile(file);
|
||||
if (decryptedUrl) {
|
||||
const link = document.createElement("a");
|
||||
link.href = decryptedUrl;
|
||||
link.download = file.name || "file";
|
||||
link.click();
|
||||
updateDownloadingPaths(draft => {
|
||||
draft.delete(file.path);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If not decrypted or public file, fetch with credentials/headers
|
||||
const response = await fetch(file.path, {
|
||||
headers: user.authToken ? api.user.auth.getAuthHeaders(user.authToken) : undefined,
|
||||
@@ -513,16 +557,19 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
</Quote>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`${styles.messageContent} ${isEmojiMessage ? styles.emojiContent : ""} ${isSingleEmojiMessage ? styles.singleEmojiContent : ""}`}
|
||||
dangerouslySetInnerHTML={formattedMessage}
|
||||
onClick={handleLinkClick} />
|
||||
{messageText.length > 0 && (
|
||||
<div
|
||||
className={`${styles.messageContent} ${isEmojiMessage ? styles.emojiContent : ""} ${isSingleEmojiMessage ? styles.singleEmojiContent : ""}`}
|
||||
dangerouslySetInnerHTML={formattedMessage}
|
||||
onClick={handleLinkClick} />
|
||||
)}
|
||||
|
||||
{message.files && message.files.length > 0 && (
|
||||
<MaterialList className={styles.messageAttachments}>
|
||||
{message.files.map((file, idx) => {
|
||||
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
|
||||
const isEncryptedDm = Boolean(isDm && file.encrypted);
|
||||
const looksEncryptedPath = /\/uploads\/files\/encrypted\//.test(file.path) || /\/api\/uploads\/files\/encrypted\//.test(file.path);
|
||||
const isEncryptedDm = Boolean(isDm && (file.encrypted || looksEncryptedPath));
|
||||
const decryptedUrl = decryptedFiles.get(file.path);
|
||||
const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined;
|
||||
const isDownloading = downloadingPaths.has(file.path);
|
||||
@@ -532,17 +579,19 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
<div className={styles.attachment} key={idx}>
|
||||
{isImage ? (
|
||||
<div className={styles.imageWrapper}>
|
||||
<img
|
||||
ref={(el) => {
|
||||
if (el) imageRefs.current.set(file.path, el);
|
||||
}}
|
||||
src={imageSrc}
|
||||
alt={file.name || "image"}
|
||||
onClick={(e) => handleImageClick(file, e.currentTarget)}
|
||||
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
|
||||
className={`${styles.attachementImage} ${loadedImages.has(file.path) ? "" : styles.loading}`}
|
||||
/>
|
||||
{(!loadedImages.has(file.path) || isSending) && (
|
||||
{isEncryptedDm && !decryptedUrl ? null : (
|
||||
<img
|
||||
ref={(el) => {
|
||||
if (el) imageRefs.current.set(file.path, el);
|
||||
}}
|
||||
src={imageSrc}
|
||||
alt={file.name || "image"}
|
||||
onClick={(e) => handleImageClick(file, e.currentTarget)}
|
||||
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
|
||||
className={`${styles.attachementImage} ${loadedImages.has(file.path) ? "" : styles.loading}`}
|
||||
/>
|
||||
)}
|
||||
{((isEncryptedDm && !decryptedUrl) || !loadedImages.has(file.path) || isSending) && (
|
||||
<div className={styles.loadingOverlay}>
|
||||
<MaterialCircularProgress />
|
||||
</div>
|
||||
|
||||
@@ -340,7 +340,6 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
<ChatMessages
|
||||
messages={panelState.messages}
|
||||
isDm={panel.isDm()}
|
||||
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
|
||||
onReplySelect={(message) => {
|
||||
if (editMessage || editVisible) {
|
||||
setPendingAction({ type: "reply", message: message });
|
||||
|
||||
@@ -55,7 +55,13 @@ export class DMPanel extends MessagePanel {
|
||||
}
|
||||
|
||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
||||
const plaintext = await api.chats.dm.decrypt(env, this.dmData!.publicKey);
|
||||
console.log("🔔 DMPanel parsing message:", {
|
||||
envelopeId: env.id,
|
||||
currentUserId: this.currentUser.currentUser?.id,
|
||||
envelopeRecipientId: env.recipientId,
|
||||
envelopeSenderId: env.senderId
|
||||
});
|
||||
const plaintext = await api.chats.dm.decrypt(env, this.currentUser.currentUser?.id);
|
||||
const username = formatDMUsername(
|
||||
env.senderId,
|
||||
env.recipientId,
|
||||
@@ -182,31 +188,24 @@ export class DMPanel extends MessagePanel {
|
||||
}
|
||||
|
||||
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
|
||||
|
||||
const payload: DmEncryptedJSON = {
|
||||
type: "text",
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? undefined
|
||||
}
|
||||
}
|
||||
const json = JSON.stringify(payload);
|
||||
if (!this.currentUser.authToken || !this.dmData || (!content.trim() && files.length === 0)) return;
|
||||
|
||||
if (files.length === 0) {
|
||||
await api.chats.dm.send(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
json,
|
||||
this.currentUser.authToken
|
||||
content.trim(),
|
||||
this.currentUser.authToken,
|
||||
replyToId
|
||||
);
|
||||
} else {
|
||||
await api.chats.dm.sendWithFiles(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
json,
|
||||
files,
|
||||
this.currentUser.authToken
|
||||
content.trim(),
|
||||
this.currentUser.authToken,
|
||||
replyToId
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -259,7 +258,7 @@ export class DMPanel extends MessagePanel {
|
||||
}
|
||||
}
|
||||
if (response.type === "dmEdited" && this.dmData) {
|
||||
const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
|
||||
const { id, iv, ciphertext, wrappedMk } = response.data;
|
||||
try {
|
||||
// Decrypt new content in-place
|
||||
const plaintext = await api.chats.dm.decrypt(
|
||||
@@ -267,14 +266,12 @@ export class DMPanel extends MessagePanel {
|
||||
id,
|
||||
senderId: 0,
|
||||
recipientId: 0,
|
||||
iv,
|
||||
ciphertext,
|
||||
salt,
|
||||
iv2,
|
||||
wrappedMk,
|
||||
iv_b64: iv,
|
||||
ciphertext_b64: ciphertext,
|
||||
wrapped_mek_b64: wrappedMk,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
this.dmData.publicKey
|
||||
this.currentUser.currentUser?.id
|
||||
);
|
||||
let content = plaintext;
|
||||
let files: Message["files"] | undefined = undefined;
|
||||
@@ -369,19 +366,26 @@ export class DMPanel extends MessagePanel {
|
||||
|
||||
async handleEditMessage(messageId: number, content: string): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData) return;
|
||||
const msg = this.getMessages().find(m => m.id === messageId);
|
||||
// Build encrypted JSON preserving files and reply_to if present
|
||||
const payload: EncryptedMessageJson = {
|
||||
type: "text",
|
||||
data: {
|
||||
content: content,
|
||||
files: msg?.files,
|
||||
reply_to_id: msg?.reply_to?.id ?? undefined
|
||||
}
|
||||
};
|
||||
api.chats.dm.edit(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
|
||||
console.error("Failed to edit DM:", e);
|
||||
});
|
||||
|
||||
try {
|
||||
await api.chats.dm.editMessage(
|
||||
messageId,
|
||||
this.dmData.publicKey,
|
||||
content.trim(),
|
||||
this.currentUser.authToken
|
||||
);
|
||||
|
||||
// Update the message in the UI
|
||||
this.updateMessage(messageId, {
|
||||
content: content.trim(),
|
||||
is_edited: true
|
||||
});
|
||||
|
||||
// Send WebSocket updates will be handled by the server
|
||||
} catch (error) {
|
||||
console.error("Failed to edit DM:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getProfile(): Promise<ProfileDialogData | null> {
|
||||
|
||||
@@ -88,7 +88,7 @@ export class PublicChatPanel extends MessagePanel {
|
||||
}
|
||||
|
||||
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
|
||||
if (!this.currentUser.authToken || !content.trim()) return;
|
||||
if (!this.currentUser.authToken || (!content.trim() && files.length === 0)) return;
|
||||
|
||||
if (files.length === 0) {
|
||||
await api.chats.general.send(content, replyToId ?? null, this.currentUser.authToken);
|
||||
|
||||
@@ -33,7 +33,17 @@ export function delay(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
|
||||
export function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); }
|
||||
export function b64(a: Uint8Array): string {
|
||||
// Avoid spreading large arrays into String.fromCharCode (stack overflow).
|
||||
const chunkSize = 0x8000; // 32KB
|
||||
let binary = "";
|
||||
for (let i = 0; i < a.length; i += chunkSize) {
|
||||
const slice = a.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode.apply(null, Array.from(slice) as number[]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function ub64(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
|
||||
Reference in New Issue
Block a user