mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 03:25:07 +03:00
Implement Signal Protocol
This commit is contained in:
@@ -127,6 +127,7 @@ export async function deriveAuthSecret(username: string, password: string): Prom
|
||||
return b64(derived);
|
||||
}
|
||||
|
||||
|
||||
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
||||
// Try to restore from backup
|
||||
const blobJson = await fetchBackupBlob(token);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
|
||||
|
||||
/**
|
||||
* Fetches the current user's public key
|
||||
@@ -74,3 +75,32 @@ export async function uploadBackupBlob(blobJson: string, token: string): Promise
|
||||
if (!res.ok) throw new Error("Failed to upload backup blob");
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads Signal Protocol prekey bundle for the current user
|
||||
*/
|
||||
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
|
||||
const payload = { bundle };
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload prekey bundle");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Signal Protocol prekey bundle for another user
|
||||
*/
|
||||
export async function fetchPreKeyBundle(userId: number, token: string): Promise<any | null> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, {
|
||||
method: "GET",
|
||||
headers
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.bundle || null;
|
||||
}
|
||||
|
||||
|
||||
+14
-176
@@ -1,178 +1,16 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { randomBytes } from "@/utils/crypto/kdf";
|
||||
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 { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
// Re-export from dmApi.ts which has Signal Protocol support
|
||||
export {
|
||||
decryptDm,
|
||||
fetchDMHistory,
|
||||
sendDMViaWebSocket,
|
||||
sendDmWithFiles,
|
||||
editDmEnvelope,
|
||||
deleteDmEnvelope,
|
||||
fetchDMConversations,
|
||||
fetchUsers,
|
||||
searchUsers,
|
||||
fetchUserPublicKey
|
||||
} from "./dmApi";
|
||||
|
||||
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 fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Encryption key
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Encrypt the message
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
};
|
||||
if (replyToId) payload.replyToId = replyToId;
|
||||
|
||||
await request({
|
||||
type: "dmSend",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const form = new FormData();
|
||||
const names: string[] = [];
|
||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||
}
|
||||
|
||||
for (const f of files) {
|
||||
// Encrypt file with same mk
|
||||
const data = new Uint8Array(await f.arrayBuffer());
|
||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
||||
const serverName = f.name; // server uses provided name
|
||||
names.push(serverName);
|
||||
form.append("files", new File([blob], serverName));
|
||||
}
|
||||
form.append("fileNames", JSON.stringify(names));
|
||||
|
||||
// Merge files metadata into plaintext JSON and encrypt
|
||||
let obj: DmEncryptedJSON;
|
||||
try {
|
||||
obj = JSON.parse(plaintextJson);
|
||||
} catch {
|
||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
||||
}
|
||||
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
||||
form.append("dm_payload", JSON.stringify({
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
} satisfies BaseDmEnvelope));
|
||||
|
||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, false),
|
||||
body: form
|
||||
});
|
||||
}
|
||||
|
||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
await request({
|
||||
type: "dmEdit",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: {
|
||||
id,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext),
|
||||
salt: b64(wkSalt)
|
||||
}
|
||||
} as DMEditRequest);
|
||||
}
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface DMConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
export type { DMConversationResponse } from "./dmApi";
|
||||
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
|
||||
import { randomBytes } from "@/utils/crypto/kdf";
|
||||
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 { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUserPublicKey, fetchPreKeyBundle } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise<string> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
// 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);
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
// Parse Signal Protocol message
|
||||
const signalCiphertext = JSON.parse(envelope.ciphertext);
|
||||
if (!signalCiphertext.type || !signalCiphertext.body) {
|
||||
throw new Error("Invalid Signal Protocol message format");
|
||||
}
|
||||
|
||||
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
@@ -34,31 +39,36 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Encryption key
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Encrypt the message
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
export async function sendDMViaWebSocket(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||
const hasSession = await signalService.hasSession(recipientId);
|
||||
if (!hasSession) {
|
||||
// Fetch prekey bundle from server
|
||||
const bundle = await fetchPreKeyBundle(recipientId, authToken);
|
||||
if (!bundle) {
|
||||
throw new Error("No Signal Protocol prekey bundle available for recipient");
|
||||
}
|
||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||
}
|
||||
|
||||
// Encrypt with Signal Protocol
|
||||
const ciphertext = await signalService.encryptMessage(recipientId, plaintext);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
iv: "", // Not used for Signal Protocol
|
||||
ciphertext: JSON.stringify(ciphertext), // Store Signal Protocol message as JSON
|
||||
salt: "", // Not used for Signal Protocol
|
||||
iv2: "", // Not used for Signal Protocol
|
||||
wrappedMk: "" // Not used for Signal Protocol
|
||||
};
|
||||
if (replyToId) payload.replyToId = replyToId;
|
||||
|
||||
@@ -78,7 +88,7 @@ export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64
|
||||
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
@@ -133,7 +143,7 @@ export async function editDmEnvelope(id: number, recipientPublicKeyB64: string,
|
||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||
@@ -167,6 +177,9 @@ export interface DMConversationResponse {
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { randomBytes } from "@/utils/crypto/kdf";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
import api from "@/core/api";
|
||||
import type { WrappedSessionKeyPayload } from "@/core/types";
|
||||
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { fetchPreKeyBundle } from "@/core/api/crypto";
|
||||
import { getAuthToken } from "@/core/api/account";
|
||||
|
||||
export interface CallSessionKey {
|
||||
key: Uint8Array;
|
||||
hash: string; // For emoji display
|
||||
}
|
||||
|
||||
export interface CallKeyExchange {
|
||||
type: "call_key_exchange";
|
||||
sessionKeyHash: string;
|
||||
encryptedSessionKey: EncryptedCallMessage;
|
||||
}
|
||||
|
||||
export interface EncryptedCallMessage {
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedSessionKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new call session key for end-to-end encryption
|
||||
* @returns Promise that resolves to a session key with its hash for display
|
||||
@@ -60,97 +46,6 @@ export async function rotateCallSessionKey(): Promise<CallSessionKey> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create session key from hash (for backward compatibility)
|
||||
* @deprecated Use deriveCallSessionKeyFromSharedSecret instead
|
||||
*/
|
||||
export async function createCallSessionKeyFromHash(hash: string): Promise<CallSessionKey> {
|
||||
// For backward compatibility, generate a deterministic key from the hash
|
||||
const hashBytes = ub64(hash);
|
||||
const sessionKey = new Uint8Array(32);
|
||||
|
||||
// Repeat the hash bytes to fill 32 bytes
|
||||
for (let i = 0; i < 32; i++) {
|
||||
sessionKey[i] = hashBytes[i % hashBytes.length];
|
||||
}
|
||||
|
||||
return {
|
||||
key: sessionKey,
|
||||
hash
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive session key from ECDH shared secret and session key hash
|
||||
* This creates a deterministic but cryptographically secure key
|
||||
*/
|
||||
export async function deriveCallSessionKeyFromSharedSecret(
|
||||
sharedSecret: Uint8Array,
|
||||
sessionKeyHash: string,
|
||||
isInitiator: boolean
|
||||
): Promise<CallSessionKey> {
|
||||
// Use HKDF to derive the session key from the shared secret
|
||||
// Include the session key hash and role to ensure uniqueness
|
||||
const info = new TextEncoder().encode(`call-session-${sessionKeyHash}-${isInitiator ? 'initiator' : 'receiver'}`);
|
||||
const salt = new Uint8Array(32); // Zero salt for deterministic derivation
|
||||
|
||||
// Import the shared secret as a raw key for HKDF
|
||||
const sharedKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
sharedSecret.buffer as ArrayBuffer,
|
||||
{ name: 'HKDF' },
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
// Derive the session key using HKDF
|
||||
const sessionKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: salt,
|
||||
info: info
|
||||
},
|
||||
sharedKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true, // Make the key extractable so we can export it
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// Export the raw key material
|
||||
const sessionKeyMaterial = await crypto.subtle.exportKey('raw', sessionKey);
|
||||
|
||||
return {
|
||||
key: new Uint8Array(sessionKeyMaterial),
|
||||
hash: sessionKeyHash
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a call signaling message with the session key
|
||||
*/
|
||||
export async function encryptCallMessage(message: Record<string, unknown>, sessionKey: Uint8Array): Promise<EncryptedCallMessage> {
|
||||
const messageKey = await importAesGcmKey(sessionKey);
|
||||
const encrypted = await aesGcmEncrypt(messageKey, new TextEncoder().encode(JSON.stringify(message)));
|
||||
|
||||
return {
|
||||
iv: b64(encrypted.iv),
|
||||
ciphertext: b64(encrypted.ciphertext),
|
||||
salt: "", // Not used for message encryption, only for key wrapping
|
||||
iv2: "",
|
||||
wrappedSessionKey: ""
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a call signaling message
|
||||
*/
|
||||
export async function decryptCallMessage(encryptedMessage: EncryptedCallMessage, sessionKey: Uint8Array): Promise<Record<string, unknown>> {
|
||||
const messageKey = await importAesGcmKey(sessionKey);
|
||||
const decrypted = await aesGcmDecrypt(messageKey, ub64(encryptedMessage.iv), ub64(encryptedMessage.ciphertext));
|
||||
return JSON.parse(new TextDecoder().decode(decrypted));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate 4 emojis representing the call session key
|
||||
*/
|
||||
@@ -176,63 +71,65 @@ export function generateCallEmojis(sessionKeyHash: string): string[] {
|
||||
return emojis;
|
||||
}
|
||||
|
||||
// HKDF info for CALL key wrapping (distinct from DM's info)
|
||||
const CALL_INFO = new Uint8Array([2]);
|
||||
|
||||
/**
|
||||
* Wraps a call session key for a specific recipient using ECDH key exchange
|
||||
* @param recipientPublicKeyB64 - The recipient's public key in base64 format
|
||||
* @param sessionKey - The session key to wrap
|
||||
* @returns Promise that resolves to the wrapped session key payload
|
||||
* Encrypts a call session key using Signal Protocol
|
||||
* @param recipientId - The recipient's user ID
|
||||
* @param sessionKey - The session key to encrypt
|
||||
* @returns Promise that resolves to encrypted session key data
|
||||
*/
|
||||
export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise<WrappedSessionKeyPayload> {
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
export async function encryptCallSessionKey(recipientId: number, sessionKey: Uint8Array): Promise<{ type: number; body: string }> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
const salt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const wrap = await aesGcmEncrypt(wk, sessionKey);
|
||||
return {
|
||||
salt: b64(salt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrapped: b64(wrap.ciphertext)
|
||||
};
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
// Ensure we have a session with the recipient
|
||||
const hasSession = await signalService.hasSession(recipientId);
|
||||
if (!hasSession) {
|
||||
// Fetch prekey bundle and establish session
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
throw new Error("No auth token");
|
||||
}
|
||||
|
||||
const bundle = await fetchPreKeyBundle(recipientId, token);
|
||||
if (!bundle) {
|
||||
throw new Error("No prekey bundle available for recipient");
|
||||
}
|
||||
|
||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||
}
|
||||
|
||||
// Encrypt the session key using Signal Protocol
|
||||
const sessionKeyString = b64(sessionKey);
|
||||
const encrypted = await signalService.encryptMessage(recipientId, sessionKeyString);
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a shared secret and derive session key for the receiver
|
||||
* Decrypts a call session key using Signal Protocol
|
||||
* @param senderId - The sender's user ID
|
||||
* @param encryptedKey - The encrypted session key data
|
||||
* @returns Promise that resolves to the decrypted session key
|
||||
*/
|
||||
export async function createSharedSecretAndDeriveSessionKey(
|
||||
senderPublicKeyB64: string,
|
||||
sessionKeyHash: string,
|
||||
isInitiator: boolean
|
||||
): Promise<CallSessionKey> {
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
export async function decryptCallSessionKey(senderId: number, encryptedKey: { type: number; body: string }): Promise<Uint8Array> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
// Create shared secret using ECDH
|
||||
const sharedSecret = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
|
||||
// Derive the session key from the shared secret
|
||||
return await deriveCallSessionKeyFromSharedSecret(sharedSecret, sessionKeyHash, isInitiator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps a call session key received from a sender using ECDH key exchange
|
||||
* @param senderPublicKeyB64 - The sender's public key in base64 format
|
||||
* @param payload - The wrapped session key payload
|
||||
* @returns Promise that resolves to the unwrapped session key
|
||||
*/
|
||||
export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise<Uint8Array> {
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const salt = ub64(payload.salt);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const sessionKey = await aesGcmDecrypt(wk, ub64(payload.iv2), ub64(payload.wrapped));
|
||||
return new Uint8Array(sessionKey);
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
// Decrypt using Signal Protocol
|
||||
const decryptedString = await signalService.decryptMessage(senderId, encryptedKey);
|
||||
|
||||
// Convert back to Uint8Array
|
||||
const sessionKey = new Uint8Array(
|
||||
atob(decryptedString).split("").map(c => c.charCodeAt(0))
|
||||
);
|
||||
|
||||
return sessionKey;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData } from "@/core/types";
|
||||
import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData, CallSessionKeyData } from "@/core/types";
|
||||
import * as WebRTC from "./webrtc";
|
||||
|
||||
export interface CallState {
|
||||
@@ -133,12 +133,16 @@ export class CallSignalingHandler {
|
||||
|
||||
private handleCallSessionKey(message: CallSignalingMessage) {
|
||||
const state = this.getState();
|
||||
const { sessionKeyHash, data } = message;
|
||||
const { sessionKeyHash } = message;
|
||||
const data = message.data as CallSessionKeyData;
|
||||
|
||||
if (sessionKeyHash) {
|
||||
state.setCallSessionKeyHash(sessionKeyHash);
|
||||
}
|
||||
if (data && 'wrappedSessionKey' in data && data.wrappedSessionKey && message.fromUserId) {
|
||||
WebRTC.receiveWrappedSessionKey(message.fromUserId, data.wrappedSessionKey, sessionKeyHash);
|
||||
|
||||
// Check if data is CallSessionKeyData and has encryptedSessionKey
|
||||
if (data && data.encryptedSessionKey && message.fromUserId) {
|
||||
WebRTC.receiveWrappedSessionKey(message.fromUserId, data.encryptedSessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import api from "@/core/api";
|
||||
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types";
|
||||
import type { CallSignalingMessage } from "@/core/types";
|
||||
import { request } from "@/core/websocket";
|
||||
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption";
|
||||
import { encryptCallSessionKey, decryptCallSessionKey, rotateCallSessionKey } from "./encryption";
|
||||
import { importAesGcmKey } from "@/utils/crypto/symmetric";
|
||||
import E2EEWorker from "./e2eeWorker?worker";
|
||||
import { delay } from "@/utils/utils";
|
||||
@@ -855,21 +855,18 @@ export async function sendCallSessionKey(userId: number, sessionKeyHash: string)
|
||||
|
||||
export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise<void> {
|
||||
try {
|
||||
const recipientPublicKey = await api.chats.dm.fetchUserPublicKey(userId, api.user.auth.getAuthToken()!);
|
||||
if (!recipientPublicKey) {
|
||||
console.warn("No recipient public key for", userId);
|
||||
return;
|
||||
}
|
||||
const wrapped = await wrapCallSessionKeyForRecipient(recipientPublicKey, sessionKey);
|
||||
// Encrypt session key using Signal Protocol
|
||||
const encrypted = await encryptCallSessionKey(userId, sessionKey);
|
||||
|
||||
await sendSignalingMessage({
|
||||
type: "call_session_key",
|
||||
fromUserId: 0,
|
||||
toUserId: userId,
|
||||
sessionKeyHash,
|
||||
data: { wrappedSessionKey: wrapped }
|
||||
data: { encryptedSessionKey: encrypted }
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send wrapped session key:", e);
|
||||
console.error("Failed to send encrypted session key:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,31 +883,21 @@ export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promi
|
||||
|
||||
export async function receiveWrappedSessionKey(
|
||||
fromUserId: number,
|
||||
wrappedPayload: WrappedSessionKeyPayload,
|
||||
sessionKeyHash?: string
|
||||
encryptedKey: { type: number; body: string }
|
||||
): Promise<void> {
|
||||
try {
|
||||
const senderPublicKey = await api.chats.dm.fetchUserPublicKey(fromUserId, api.user.auth.getAuthToken()!);
|
||||
if (!senderPublicKey) {
|
||||
console.error("Failed to get sender public key");
|
||||
return;
|
||||
}
|
||||
if (!wrappedPayload || !sessionKeyHash) {
|
||||
console.error("Missing wrapped payload or session key hash");
|
||||
if (!encryptedKey) {
|
||||
console.error("Missing encrypted session key");
|
||||
return;
|
||||
}
|
||||
|
||||
// Unwrap the session key from the encrypted payload
|
||||
const unwrappedSessionKey = await unwrapCallSessionKeyFromSender(senderPublicKey, {
|
||||
salt: wrappedPayload.salt,
|
||||
iv2: wrappedPayload.iv2,
|
||||
wrapped: wrappedPayload.wrapped
|
||||
});
|
||||
// Decrypt the session key using Signal Protocol
|
||||
const sessionKey = await decryptCallSessionKey(fromUserId, encryptedKey);
|
||||
|
||||
// Use the unwrapped session key directly (both sides should have the same key)
|
||||
await setSessionKey(fromUserId, unwrappedSessionKey);
|
||||
// Use the decrypted session key for media encryption
|
||||
await setSessionKey(fromUserId, sessionKey);
|
||||
} catch (e) {
|
||||
console.error("Failed to unwrap session key:", e);
|
||||
console.error("Failed to decrypt session key:", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-7
@@ -505,7 +505,7 @@ export interface CallEndData {
|
||||
}
|
||||
|
||||
export interface CallSessionKeyData {
|
||||
wrappedSessionKey?: WrappedSessionKeyPayload;
|
||||
encryptedSessionKey: { type: number; body: string };
|
||||
}
|
||||
|
||||
export interface CallVideoToggleData {
|
||||
@@ -526,12 +526,6 @@ export interface CallScreenShareToggleMessageData {
|
||||
data: CallScreenShareToggleData;
|
||||
}
|
||||
|
||||
export interface WrappedSessionKeyPayload {
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrapped: string;
|
||||
}
|
||||
|
||||
export interface CallVideoToggleMessage extends CallSignalingMessage {
|
||||
type: "call_video_toggle";
|
||||
data: CallVideoToggleData;
|
||||
|
||||
Reference in New Issue
Block a user