Restructure backend into microservices, add envelope encryption, DM files, and message editing

This commit is contained in:
2026-01-10 14:59:31 +03:00
Unverified
parent 1f706eaa34
commit fd4c00057c
74 changed files with 6647 additions and 820 deletions
+257 -84
View File
@@ -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
View File
@@ -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;
}
+6 -129
View File
@@ -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({
-19
View File
@@ -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
+12 -10
View File
@@ -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;
}
// -----------------------
+28 -12
View File
@@ -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>
+84 -35
View File
@@ -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);
+11 -1
View File
@@ -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);