Implement centralized Signal Protocol init

This commit is contained in:
2025-12-03 20:40:36 +03:00
Unverified
parent 4b5de53900
commit c5d5cdf9d2
19 changed files with 1023 additions and 111 deletions
+14
View File
@@ -106,6 +106,20 @@ class SignalSession(Base):
__table_args__ = (UniqueConstraint('user_id', 'recipient_id', 'device_id', name='_user_recipient_device_uc'),) __table_args__ = (UniqueConstraint('user_id', 'recipient_id', 'device_id', name='_user_recipient_device_uc'),)
class SentMessagePlaintext(Base):
"""Stores encrypted plaintexts of sent messages for history display"""
__tablename__ = "sent_message_plaintext"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
message_id = Column(Integer, nullable=False, index=True) # DM envelope ID
recipient_id = Column(Integer, nullable=False, index=True) # The recipient of the message
encrypted_data = Column(Text, nullable=False) # Encrypted plaintext (JSON with salt, iv, ciphertext)
created_at = Column(DateTime, default=datetime.now, index=True)
__table_args__ = (UniqueConstraint('user_id', 'message_id', name='_user_message_uc'),)
class DMEnvelope(Base): class DMEnvelope(Base):
__tablename__ = "dm_envelope" __tablename__ = "dm_envelope"
+88 -1
View File
@@ -10,7 +10,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
from dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession, SignalSession from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession, SignalSession, SentMessagePlaintext
from utils import create_token, get_password_hash, verify_password, get_client_ip from utils import create_token, get_password_hash, verify_password, get_client_ip
from validation import is_valid_password, is_valid_username, is_valid_display_name from validation import is_valid_password, is_valid_username, is_valid_display_name
import os import os
@@ -790,6 +790,93 @@ def get_signal_sessions(
} }
@router.post("/crypto/signal/message-plaintexts")
@rate_limit_per_ip("100/minute")
def upload_message_plaintexts(
request: Request,
payload: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Upload encrypted plaintexts of sent messages"""
import json
from datetime import datetime
messages = payload.get("messages")
if not isinstance(messages, list):
raise HTTPException(status_code=400, detail="messages must be a list")
uploaded_count = 0
for msg_data in messages:
if not isinstance(msg_data, dict):
continue
message_id = msg_data.get("messageId")
recipient_id = msg_data.get("recipientId")
encrypted_data = msg_data.get("encryptedData")
if not message_id or not recipient_id or not encrypted_data:
continue
try:
# Validate encrypted_data is valid JSON
json.loads(encrypted_data)
except (json.JSONDecodeError, TypeError):
continue
# Store or update plaintext
existing = db.query(SentMessagePlaintext).filter(
SentMessagePlaintext.user_id == current_user.id,
SentMessagePlaintext.message_id == message_id
).first()
if existing:
existing.encrypted_data = encrypted_data
else:
new_plaintext = SentMessagePlaintext(
user_id=current_user.id,
message_id=message_id,
recipient_id=recipient_id,
encrypted_data=encrypted_data
)
db.add(new_plaintext)
uploaded_count += 1
db.commit()
return {"status": "ok", "uploaded_count": uploaded_count}
@router.get("/crypto/signal/message-plaintexts")
@rate_limit_per_ip("60/minute")
def get_message_plaintexts(
request: Request,
recipient_id: int | None = None, # Optional filter by recipient
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get encrypted plaintexts of sent messages for the current user"""
query = db.query(SentMessagePlaintext).filter(
SentMessagePlaintext.user_id == current_user.id
)
if recipient_id is not None:
query = query.filter(SentMessagePlaintext.recipient_id == recipient_id)
plaintexts = query.all()
return {
"messages": [
{
"messageId": p.message_id,
"recipientId": p.recipient_id,
"encryptedData": p.encrypted_data,
"createdAt": p.created_at.isoformat()
}
for p in plaintexts
]
}
@router.get("/users/search") @router.get("/users/search")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+3
View File
@@ -262,6 +262,9 @@ export async function send(recipientId: number, plaintext: string, authToken: st
}, },
data: payload data: payload
}); });
// Note: We'll cache the message when we receive the dmNew confirmation via WebSocket
// which contains the actual message ID
} }
export async function sendWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> { export async function sendWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
@@ -0,0 +1,69 @@
/**
* API functions for managing encrypted message plaintexts on the server
*/
import { API_BASE_URL } from "@/core/config";
import api from "@/core/api";
export interface MessagePlaintextData {
messageId: number;
recipientId: number;
encryptedData: string;
}
export interface MessagePlaintextResponse {
messageId: number;
recipientId: number;
encryptedData: string;
createdAt: string;
}
/**
* Upload encrypted message plaintexts to the server
*/
export async function uploadMessagePlaintexts(
messages: MessagePlaintextData[],
token: string
): Promise<void> {
const response = await fetch(`${API_BASE_URL}/crypto/signal/message-plaintexts`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...api.user.auth.getAuthHeaders(token, false)
},
body: JSON.stringify({ messages })
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Failed to upload message plaintexts" }));
throw new Error(error.detail || "Failed to upload message plaintexts");
}
}
/**
* Fetch encrypted message plaintexts from the server
*/
export async function fetchMessagePlaintexts(
token: string,
recipientId?: number
): Promise<MessagePlaintextResponse[]> {
let url = `${API_BASE_URL}/crypto/signal/message-plaintexts`;
if (recipientId !== undefined) {
const separator = url.includes("?") ? "&" : "?";
url = `${url}${separator}recipient_id=${recipientId}`;
}
const response = await fetch(url, {
method: "GET",
headers: api.user.auth.getAuthHeaders(token, false)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Failed to fetch message plaintexts" }));
throw new Error(error.detail || "Failed to fetch message plaintexts");
}
const data = await response.json();
return data.messages || [];
}
+17 -1
View File
@@ -36,6 +36,9 @@ export async function uploadSessions(sessions: SessionData[], token: string): Pr
* Fetch all encrypted Signal Protocol sessions from the server * Fetch all encrypted Signal Protocol sessions from the server
*/ */
export async function fetchSessions(token: string): Promise<SessionData[]> { export async function fetchSessions(token: string): Promise<SessionData[]> {
console.log("[Session API] Fetching sessions from server...");
console.log("[Session API] URL:", `${API_BASE_URL}/crypto/signal/sessions`);
const headers = getAuthHeaders(token, true); const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, { const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, {
@@ -43,11 +46,24 @@ export async function fetchSessions(token: string): Promise<SessionData[]> {
headers headers
}); });
console.log("[Session API] Response status:", res.status, res.statusText);
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to fetch sessions: ${res.statusText}`); const errorText = await res.text().catch(() => "Unknown error");
console.error("[Session API] Failed to fetch sessions:", {
status: res.status,
statusText: res.statusText,
errorText
});
throw new Error(`Failed to fetch sessions: ${res.status} ${res.statusText}`);
} }
const data = await res.json(); const data = await res.json();
console.log("[Session API] Response data:", {
hasSessions: !!data.sessions,
sessionCount: data.sessions?.length || 0
});
return data.sessions || []; return data.sessions || [];
} }
+3 -1
View File
@@ -8,6 +8,7 @@ import * as cryptoPrekeys from "./crypto/prekeys";
import * as cryptoIdentity from "./crypto/identity"; import * as cryptoIdentity from "./crypto/identity";
import * as cryptoBackup from "./crypto/backup"; import * as cryptoBackup from "./crypto/backup";
import * as cryptoSessions from "./crypto/sessions"; import * as cryptoSessions from "./crypto/sessions";
import * as cryptoMessagePlaintexts from "./crypto/messagePlaintexts";
import * as moderationBlocklist from "./moderation/blocklist"; import * as moderationBlocklist from "./moderation/blocklist";
import * as moderationUsers from "./moderation/users"; import * as moderationUsers from "./moderation/users";
import * as callsModule from "./calls"; import * as callsModule from "./calls";
@@ -29,7 +30,8 @@ const api = {
prekeys: cryptoPrekeys, prekeys: cryptoPrekeys,
identity: cryptoIdentity, identity: cryptoIdentity,
backup: cryptoBackup, backup: cryptoBackup,
sessions: cryptoSessions sessions: cryptoSessions,
messagePlaintexts: cryptoMessagePlaintexts
}, },
moderation: { moderation: {
blocklist: moderationBlocklist, blocklist: moderationBlocklist,
+39 -34
View File
@@ -79,7 +79,14 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
setIsLoading(true); setIsLoading(true);
console.log("========================================");
console.log("[LoginForm] 🚀 LOGIN FORM SUBMITTED");
console.log("[LoginForm] Username:", username);
console.log("[LoginForm] Has password:", !!password);
console.log("========================================");
try { try {
console.log("[LoginForm] Deriving auth secret...");
const derived = await api.user.auth.deriveAuthSecret(username, password); const derived = await api.user.auth.deriveAuthSecret(username, password);
const request: LoginRequest = { const request: LoginRequest = {
username: username, username: username,
@@ -87,54 +94,52 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
} }
try { try {
console.log("[LoginForm] Calling login API...");
const data = await api.user.auth.login(request); const data = await api.user.auth.login(request);
console.log("[LoginForm] Login successful, user ID:", data.user?.id);
setUser(data.token, data.user); setUser(data.token, data.user);
try { try {
console.log("[LoginForm] Ensuring keys on login...");
await api.user.auth.ensureKeysOnLogin(password, data.token); await api.user.auth.ensureKeysOnLogin(password, data.token);
console.log("[LoginForm] Keys ensured");
// Initialize Signal Protocol after keys are set up (non-blocking) // Initialize Signal Protocol after keys are set up (non-blocking)
if (data.user?.id) { if (data.user?.id) {
console.log("[LoginForm] ✅ User ID exists, scheduling Signal Protocol initialization");
// Run Signal Protocol initialization in background to avoid blocking navigation // Run Signal Protocol initialization in background to avoid blocking navigation
(async () => { // Use setTimeout to ensure it runs even if navigation happens
setTimeout(async () => {
try { try {
console.log("Starting Signal Protocol initialization..."); console.log("[LoginForm] 🚀 Starting Signal Protocol initialization...");
const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol"); const { initializeSignalProtocol } = await import("@/utils/crypto/signalProtocolInit");
const { uploadPreKeyBundle, uploadAllPreKeys } = await import("@/core/api/crypto/prekeys"); await initializeSignalProtocol({
const { restoreSessionsFromServer, uploadAllSessionsToServer, initializeSessionSync } = await import("@/utils/crypto/sessionSync"); userId: data.user!.id.toString(),
password,
const signalService = new SignalProtocolService(data.user!.id.toString()); token: data.token,
await signalService.initialize(); restoreSessions: true,
console.log("Signal Protocol initialized"); uploadSessions: true
});
// Initialize session sync (enables automatic upload of new sessions) console.log("[LoginForm] ✅ Signal Protocol initialization completed");
console.log("Initializing session sync...");
initializeSessionSync(data.user!.id.toString(), password, data.token);
console.log("Session sync initialized");
// Restore sessions from server (encrypted with password)
await restoreSessionsFromServer(data.user!.id.toString(), password, data.token);
// Upload base bundle with one prekey (for backward compatibility)
const bundle = await signalService.getPreKeyBundle();
await uploadPreKeyBundle(bundle, data.token);
// Upload all prekeys for server-side rotation
const baseBundle = await signalService.getBaseBundle();
const prekeys = await signalService.getAllPreKeys();
await uploadAllPreKeys(baseBundle, prekeys, data.token);
// Upload all current sessions to server (backup)
await uploadAllSessionsToServer(data.user!.id.toString(), password, data.token);
console.log(`Uploaded ${prekeys.length} prekeys to server`);
} catch (e) { } catch (e) {
console.error("Key setup failed:", e); console.error("[LoginForm] ❌ Signal Protocol initialization failed:", e);
console.error("[LoginForm] Error details:", {
message: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined
});
} }
})(); }, 0);
console.log("[LoginForm] ✅ Signal Protocol initialization scheduled");
} else {
console.warn("[LoginForm] ⚠️ No user ID, skipping Signal Protocol initialization");
} }
} catch (e) { } catch (e) {
console.error("Key setup failed:", e); console.error("[LoginForm] ❌ Key setup failed:", e);
console.error("[LoginForm] Error details:", {
message: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined
});
} }
// Ensure WebSocket is connected and authenticated // Ensure WebSocket is connected and authenticated
+12 -27
View File
@@ -116,6 +116,7 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
try { try {
const data = await api.user.auth.register(request); const data = await api.user.auth.register(request);
setUser(data.token, data.user); setUser(data.token, data.user);
try { try {
@@ -123,36 +124,20 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
// Initialize Signal Protocol after keys are set up (non-blocking) // Initialize Signal Protocol after keys are set up (non-blocking)
if (data.user?.id) { if (data.user?.id) {
// Run Signal Protocol initialization in background to avoid blocking navigation setTimeout(async () => {
(async () => {
try { try {
const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol"); const { initializeSignalProtocol } = await import("@/utils/crypto/signalProtocolInit");
const { uploadPreKeyBundle, uploadAllPreKeys } = await import("@/core/api/crypto/prekeys"); await initializeSignalProtocol({
const { uploadAllSessionsToServer, initializeSessionSync } = await import("@/utils/crypto/sessionSync"); userId: data.user!.id.toString(),
password,
const signalService = new SignalProtocolService(data.user!.id.toString()); token: data.token,
await signalService.initialize(); restoreSessions: false,
uploadSessions: true
// Initialize session sync (enables automatic upload of new sessions) });
initializeSessionSync(data.user!.id.toString(), password, data.token);
// Upload base bundle with one prekey (for backward compatibility)
const bundle = await signalService.getPreKeyBundle();
await uploadPreKeyBundle(bundle, data.token);
// Upload all prekeys for server-side rotation
const baseBundle = await signalService.getBaseBundle();
const prekeys = await signalService.getAllPreKeys();
await uploadAllPreKeys(baseBundle, prekeys, data.token);
// Upload all current sessions to server (backup - will be empty on registration)
await uploadAllSessionsToServer(data.user!.id.toString(), password, data.token);
console.log(`Uploaded ${prekeys.length} prekeys to server`);
} catch (e) { } catch (e) {
console.error("Key setup failed:", e); console.error("[RegisterForm] Signal Protocol initialization failed:", e);
} }
})(); }, 0);
} }
} catch (e) { } catch (e) {
console.error("Key setup failed:", e); console.error("Key setup failed:", e);
+10 -4
View File
@@ -172,10 +172,16 @@ export function useDM() {
if (isAuthor) { if (isAuthor) {
// For sent messages, we can't decrypt them in Signal Protocol // For sent messages, we can't decrypt them in Signal Protocol
// The plaintext should be stored when sending, but for now we'll skip them // Try to get the plaintext from the server (encrypted)
// or try to get it from the envelope if available const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
// Skip this message for now - we'll need to store plaintext when sending const plaintexts = await fetchMessagePlaintextsForRecipient(userId);
continue; // Skip sent messages - they'll be handled by the send flow const cached = plaintexts.get(env.id);
if (cached) {
text = cached;
} else {
// Not on server - skip this message
continue;
}
} else { } else {
// Decrypt incoming messages // Decrypt incoming messages
text = await decryptDm(env, env.senderId); text = await decryptDm(env, env.senderId);
@@ -6,6 +6,7 @@ import type { UserState, ProfileDialogData } from "@/state/types";
import { formatDMUsername } from "@/pages/chat/hooks/useDM"; import { formatDMUsername } from "@/pages/chat/hooks/useDM";
import { onlineStatusManager } from "@/core/onlineStatusManager"; import { onlineStatusManager } from "@/core/onlineStatusManager";
import { typingManager } from "@/core/typingManager"; import { typingManager } from "@/core/typingManager";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
export interface DMPanelData { export interface DMPanelData {
userId: number; userId: number;
@@ -18,11 +19,16 @@ export interface DMPanelData {
export class DMPanel extends MessagePanel { export class DMPanel extends MessagePanel {
public dmData: DMPanelData | null = null; public dmData: DMPanelData | null = null;
private messagesLoaded: boolean = false; private messagesLoaded: boolean = false;
private signalService: SignalProtocolService | null = null;
constructor( constructor(
user: UserState user: UserState
) { ) {
super("dm", user); super("dm", user);
// Initialize Signal Protocol service if user is available
if (user.currentUser?.id) {
this.signalService = new SignalProtocolService(user.currentUser.id.toString());
}
} }
isDm(): boolean { isDm(): boolean {
@@ -64,9 +70,9 @@ export class DMPanel extends MessagePanel {
let plaintext: string; let plaintext: string;
if (isSentByUs) { if (isSentByUs) {
// Can't decrypt our own sent messages in Signal Protocol // Can't decrypt our own sent messages in Signal Protocol
// The plaintext should be stored when sending, but for now we'll skip it // The plaintext should be passed in from loadMessages (fetched from server)
// This message should have been displayed immediately when sent // For now, throw an error - the caller should handle this by fetching plaintexts first
throw new Error("Cannot decrypt own sent message - should be displayed from send flow"); throw new Error("Cannot decrypt own sent message - plaintext must be fetched from server first");
} else { } else {
// Decrypt incoming messages // Decrypt incoming messages
plaintext = await decryptDm(env, env.senderId); plaintext = await decryptDm(env, env.senderId);
@@ -119,6 +125,28 @@ export class DMPanel extends MessagePanel {
this.setLoading(true); this.setLoading(true);
try { try {
// Ensure Signal Protocol session is established before fetching messages
if (!this.signalService && this.currentUser.currentUser?.id) {
this.signalService = new SignalProtocolService(this.currentUser.currentUser.id.toString());
}
if (this.signalService) {
const hasSession = await this.signalService.hasSession(this.dmData.userId);
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(this.dmData.userId, this.currentUser.authToken);
await this.signalService.processPreKeyBundle(this.dmData.userId, bundle);
console.log(`Established new Signal Protocol session for user ${this.dmData.userId} during history load.`);
} catch (error) {
console.warn(`Failed to establish Signal Protocol session for user ${this.dmData.userId} during history load:`, error);
// Continue loading history, but decryption will likely fail for new messages
}
}
}
// Fetch encrypted plaintexts from server for sent messages
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
const limit = this.calculateMessageLimit(); const limit = this.calculateMessageLimit();
const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit); const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit);
const decryptedMessages: Message[] = []; const decryptedMessages: Message[] = [];
@@ -130,7 +158,53 @@ export class DMPanel extends MessagePanel {
if (env.id) { if (env.id) {
this.processedMessageIds.add(env.id); this.processedMessageIds.add(env.id);
} }
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
// For sent messages, use plaintext from server
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
let dmMsg: Message;
if (isSentByUs) {
const cachedPlaintext = plaintexts.get(env.id);
if (!cachedPlaintext) {
// Not on server - skip this message
continue;
}
// Parse the plaintext as if it came from parseTextPayload
const username = formatDMUsername(
env.senderId,
env.recipientId,
this.currentUser.currentUser?.id!,
this.dmData!.username
);
let content = cachedPlaintext;
let reply_to_id: number | undefined = undefined;
try {
const obj = JSON.parse(cachedPlaintext) as DmEncryptedJSON;
if (obj && obj.type === "text" && obj.data) {
content = obj.data.content;
reply_to_id = Number(obj.data.reply_to_id) || undefined;
}
} catch {}
dmMsg = {
id: env.id,
user_id: env.senderId,
content: content,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false,
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
reactions: env.reactions || [],
runtimeData: {
dmEnvelope: env
}
};
if (reply_to_id) {
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
if (referenced) dmMsg.reply_to = referenced;
}
} else {
dmMsg = await this.parseTextPayload(env, decryptedMessages);
}
decryptedMessages.push(dmMsg); decryptedMessages.push(dmMsg);
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) { if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
@@ -177,6 +251,28 @@ export class DMPanel extends MessagePanel {
this.setLoadingMore(true); this.setLoadingMore(true);
try { try {
// Ensure Signal Protocol session is established before fetching messages
if (!this.signalService && this.currentUser.currentUser?.id) {
this.signalService = new SignalProtocolService(this.currentUser.currentUser.id.toString());
}
if (this.signalService) {
const hasSession = await this.signalService.hasSession(this.dmData.userId);
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(this.dmData.userId, this.currentUser.authToken);
await this.signalService.processPreKeyBundle(this.dmData.userId, bundle);
console.log(`Established new Signal Protocol session for user ${this.dmData.userId} during more history load.`);
} catch (error) {
console.warn(`Failed to establish Signal Protocol session for user ${this.dmData.userId} during more history load:`, error);
// Continue loading history, but decryption will likely fail for new messages
}
}
}
// Fetch encrypted plaintexts from server for sent messages
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
const limit = this.calculateMessageLimit(); const limit = this.calculateMessageLimit();
const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages( const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages(
this.dmData.userId, this.dmData.userId,
@@ -189,7 +285,57 @@ export class DMPanel extends MessagePanel {
const decryptedMessages: Message[] = []; const decryptedMessages: Message[] = [];
for (const env of newEnvelopes) { for (const env of newEnvelopes) {
try { try {
const dmMsg = await this.parseTextPayload(env, decryptedMessages); // Mark as processed to prevent duplicates
if (env.id) {
this.processedMessageIds.add(env.id);
}
// For sent messages, use plaintext from server
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
let dmMsg: Message;
if (isSentByUs) {
const cachedPlaintext = plaintexts.get(env.id);
if (!cachedPlaintext) {
// Not on server - skip this message
continue;
}
// Parse the plaintext as if it came from parseTextPayload
const username = formatDMUsername(
env.senderId,
env.recipientId,
this.currentUser.currentUser?.id!,
this.dmData!.username
);
let content = cachedPlaintext;
let reply_to_id: number | undefined = undefined;
try {
const obj = JSON.parse(cachedPlaintext) as DmEncryptedJSON;
if (obj && obj.type === "text" && obj.data) {
content = obj.data.content;
reply_to_id = Number(obj.data.reply_to_id) || undefined;
}
} catch {}
dmMsg = {
id: env.id,
user_id: env.senderId,
content: content,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false,
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
reactions: env.reactions || [],
runtimeData: {
dmEnvelope: env
}
};
if (reply_to_id) {
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
if (referenced) dmMsg.reply_to = referenced;
}
} else {
dmMsg = await this.parseTextPayload(env, decryptedMessages);
}
decryptedMessages.push(dmMsg); decryptedMessages.push(dmMsg);
} catch (error) { } catch (error) {
// Silently skip messages that can't be decrypted // Silently skip messages that can't be decrypted
@@ -243,10 +389,7 @@ export class DMPanel extends MessagePanel {
const { PrekeyExhaustedError } = await import("@/core/api/crypto/prekeys"); const { PrekeyExhaustedError } = await import("@/core/api/crypto/prekeys");
if (error instanceof PrekeyExhaustedError) { if (error instanceof PrekeyExhaustedError) {
const { alert } = await import("@/core/components/AlertDialog"); const { alert } = await import("@/core/components/AlertDialog");
await alert({ await alert("Cannot Send Message: The recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys. This ensures maximum privacy and security.");
headline: "Cannot Send Message",
description: "The recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys. This ensures maximum privacy and security."
});
} }
} }
} }
@@ -298,12 +441,27 @@ export class DMPanel extends MessagePanel {
if (isOurMessage) { if (isOurMessage) {
// This is our message being confirmed, find the temp message and replace it // This is our message being confirmed, find the temp message and replace it
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId); const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
let tempMsgContent: string | null = null;
for (const tempMsg of tempMessages) { for (const tempMsg of tempMessages) {
if (tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content) { if (tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content) {
tempMsgContent = tempMsg.content; // Get plaintext from temp message
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId!, dmMsg); this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId!, dmMsg);
// Upload the plaintext to server (encrypted) so we can display it in history
const { uploadMessagePlaintext } = await import("@/utils/crypto/messagePlaintextSync");
if (tempMsgContent) {
await uploadMessagePlaintext(envelope.id, this.dmData.userId, tempMsgContent);
}
return; return;
} }
} }
// If we didn't find a temp message, try to upload from dmMsg content
// (this might happen if the page was reloaded)
if (!tempMsgContent && dmMsg.content) {
const { uploadMessagePlaintext } = await import("@/utils/crypto/messagePlaintextSync");
await uploadMessagePlaintext(envelope.id, this.dmData.userId, dmMsg.content);
}
} }
this.addMessage(dmMsg); this.addMessage(dmMsg);
+38 -5
View File
@@ -8,6 +8,7 @@ import { onlineStatusManager } from "@/core/onlineStatusManager";
import { typingManager } from "@/core/typingManager"; import { typingManager } from "@/core/typingManager";
import type { UserState } from "./types"; import type { UserState } from "./types";
import { clearSessionSync } from "@/utils/crypto/sessionSync"; import { clearSessionSync } from "@/utils/crypto/sessionSync";
import { clearMessagePlaintextSync } from "@/utils/crypto/messagePlaintextSync";
interface UserStore { interface UserStore {
user: UserState; user: UserState;
@@ -51,8 +52,9 @@ export const useUserStore = create<UserStore>((set) => ({
try { try {
localStorage.removeItem('authToken'); localStorage.removeItem('authToken');
localStorage.removeItem('currentUser'); localStorage.removeItem('currentUser');
sessionStorage.removeItem('sessionPassword');
} catch (error) { } catch (error) {
console.error('Failed to clear localStorage:', error); console.error('Failed to clear storage:', error);
} }
onlineStatusManager.setAuthToken(null); onlineStatusManager.setAuthToken(null);
@@ -62,6 +64,7 @@ export const useUserStore = create<UserStore>((set) => ({
// Clear session sync // Clear session sync
clearSessionSync(); clearSessionSync();
clearMessagePlaintextSync();
set({ set({
user: { user: {
@@ -109,17 +112,47 @@ export const useUserStore = create<UserStore>((set) => ({
typingManager.setAuthToken(token); typingManager.setAuthToken(token);
// Initialize Signal Protocol after restoring user (non-blocking) // Initialize Signal Protocol after restoring user (non-blocking)
// Note: We can't restore sessions without the password, but we can initialize // Note: We can't restore sessions without password, but we can initialize Signal Protocol
// Signal Protocol so new sessions can be created when needed
if (user.id) { if (user.id) {
(async () => { (async () => {
try { try {
const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol"); const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol");
const { uploadAllPreKeys } = await import("@/core/api/crypto/prekeys");
const { getStoredSessionKey } = await import("@/utils/crypto/sessionKeyStorage");
const { restoreSessionsFromServer } = await import("@/utils/crypto/sessionSync");
console.log("[RestoreFromStorage] Starting Signal Protocol setup...");
const signalService = new SignalProtocolService(user.id.toString()); const signalService = new SignalProtocolService(user.id.toString());
await signalService.initialize(); await signalService.initialize();
console.log("Signal Protocol initialized after restore (sessions will be re-established when needed)"); console.log("[RestoreFromStorage] Signal Protocol initialized");
// Check if we have a stored session key (derived from password)
const storedKey = getStoredSessionKey(user.id.toString());
if (storedKey) {
console.log("[RestoreFromStorage] Stored session key found, restoring sessions from server...");
// We can restore sessions using the stored key (password not needed)
try {
await restoreSessionsFromServer(user.id.toString(), null, token);
console.log("[RestoreFromStorage] Sessions restored from server using stored key");
} catch (error) {
console.error("[RestoreFromStorage] Failed to restore sessions:", error);
}
} else {
console.warn("[RestoreFromStorage] No stored session key - user needs to log in to derive key");
}
// Re-upload prekeys to ensure they are fresh
try {
const baseBundle = await signalService.getBaseBundle();
const prekeys = await signalService.getAllPreKeys();
await uploadAllPreKeys(baseBundle, prekeys, token);
console.log(`[RestoreFromStorage] Uploaded ${prekeys.length} prekeys to server`);
} catch (error) {
console.error("[RestoreFromStorage] Failed to upload prekeys:", error);
}
} catch (e) { } catch (e) {
console.error("Signal Protocol initialization failed (restored):", e); console.error("[RestoreFromStorage] Signal Protocol setup failed:", e);
} }
})(); })();
} }
+2 -2
View File
@@ -3,13 +3,13 @@ export async function importPassword(password: string): Promise<CryptoKey> {
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]); return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]);
} }
export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000): Promise<CryptoKey> { export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000, extractable = false): Promise<CryptoKey> {
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt; const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
return crypto.subtle.deriveKey( return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" }, { name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
passwordKey, passwordKey,
{ name: "AES-GCM", length: 256 }, { name: "AES-GCM", length: 256 },
false, extractable,
["encrypt", "decrypt"] ["encrypt", "decrypt"]
); );
} }
+135
View File
@@ -0,0 +1,135 @@
/**
* Message cache for storing sent message plaintexts
* Since Signal Protocol doesn't allow decrypting your own sent messages,
* we store the plaintext locally and optionally sync to server
*/
const DB_NAME = "message_cache_db";
const DB_VERSION = 1;
const STORE_NAME = "sent_messages";
let dbPromise: Promise<IDBDatabase> | null = null;
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
// Key: [userId, messageId], Value: { plaintext, timestamp }
const store = db.createObjectStore(STORE_NAME, { keyPath: ["userId", "messageId"] });
store.createIndex("userId", "userId", { unique: false });
store.createIndex("messageId", "messageId", { unique: false });
}
};
});
return dbPromise;
}
async function getStore(mode: IDBTransactionMode = "readonly"): Promise<IDBObjectStore> {
const db = await openDB();
const tx = db.transaction([STORE_NAME], mode);
return tx.objectStore(STORE_NAME);
}
interface CachedMessage {
userId: number;
messageId: number;
plaintext: string;
timestamp: string;
}
/**
* Store a sent message's plaintext in the cache
*/
export async function cacheSentMessage(userId: number, messageId: number, plaintext: string): Promise<void> {
try {
const store = await getStore("readwrite");
await new Promise<void>((resolve, reject) => {
const request = store.put({
userId,
messageId,
plaintext,
timestamp: new Date().toISOString()
});
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (error) {
console.warn("Failed to cache sent message:", error);
}
}
/**
* Retrieve a sent message's plaintext from the cache
*/
export async function getCachedMessage(userId: number, messageId: number): Promise<string | null> {
try {
const store = await getStore();
const result = await new Promise<CachedMessage | undefined>((resolve, reject) => {
const request = store.get([userId, messageId]);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return result?.plaintext || null;
} catch (error) {
console.warn("Failed to get cached message:", error);
return null;
}
}
/**
* Get all cached messages for a user
*/
export async function getAllCachedMessages(userId: number): Promise<Map<number, string>> {
const cache = new Map<number, string>();
try {
const store = await getStore();
const index = store.index("userId");
const result = await new Promise<CachedMessage[]>((resolve, reject) => {
const request = index.getAll(userId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
result.forEach(msg => {
cache.set(msg.messageId, msg.plaintext);
});
} catch (error) {
console.warn("Failed to get all cached messages:", error);
}
return cache;
}
/**
* Clear cached messages for a user (e.g., on logout)
*/
export async function clearCachedMessages(userId: number): Promise<void> {
try {
const store = await getStore("readwrite");
const index = store.index("userId");
await new Promise<void>((resolve, reject) => {
const request = index.openCursor(IDBKeyRange.only(userId));
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
cursor.delete();
cursor.continue();
} else {
resolve();
}
};
request.onerror = () => reject(request.error);
});
} catch (error) {
console.warn("Failed to clear cached messages:", error);
}
}
@@ -0,0 +1,23 @@
/**
* Functions for encrypting/decrypting sent message plaintexts
* Uses password-derived key (same as session encryption)
*/
import { encryptSessionWithPassword, decryptSessionWithPassword, encodeSessionBlob, decodeSessionBlob } from "./sessionEncryption";
/**
* Encrypt message plaintext using password-derived key
*/
export async function encryptMessagePlaintext(password: string | null, userId: string, plaintext: string): Promise<string> {
const encrypted = await encryptSessionWithPassword(password, userId, plaintext);
return encodeSessionBlob(encrypted);
}
/**
* Decrypt message plaintext using password-derived key
*/
export async function decryptMessagePlaintext(password: string | null, userId: string, encryptedData: string): Promise<string> {
const blob = decodeSessionBlob(encryptedData);
return await decryptSessionWithPassword(password, userId, blob);
}
@@ -0,0 +1,108 @@
/**
* Service for syncing sent message plaintexts with the server
* Plaintexts are encrypted with password-derived key and stored on server
*/
import { encryptMessagePlaintext, decryptMessagePlaintext } from "./messagePlaintextEncryption";
import { uploadMessagePlaintexts, fetchMessagePlaintexts, type MessagePlaintextData } from "@/core/api/crypto/messagePlaintexts";
// Global state for message plaintext sync
let syncPassword: string | null = null;
let syncToken: string | null = null;
let syncUserId: string | null = null;
/**
* Initialize message plaintext sync - stores password and userId for encryption
* Called after login when password is available
*/
export function initializeMessagePlaintextSync(userId: string, password: string, token: string): void {
console.log("Initializing message plaintext sync for user", userId);
syncUserId = userId;
syncPassword = password;
syncToken = token;
}
/**
* Clear message plaintext sync - called on logout
*/
export function clearMessagePlaintextSync(): void {
console.log("Clearing message plaintext sync state");
syncUserId = null;
syncPassword = null;
syncToken = null;
}
/**
* Upload a sent message's plaintext to the server (encrypted)
* Called when a message is sent and confirmed
*/
export async function uploadMessagePlaintext(
messageId: number,
recipientId: number,
plaintext: string
): Promise<void> {
if (!syncPassword || !syncToken || !syncUserId) {
console.warn("Message plaintext sync not initialized (missing password/token/userId)");
return; // Not initialized yet
}
try {
console.log(`Encrypting plaintext for message ${messageId}...`);
// Use stored key if available, otherwise use password to derive it
const encryptedData = await encryptMessagePlaintext(syncPassword, syncUserId, plaintext);
console.log(`Uploading plaintext for message ${messageId} to server...`);
await uploadMessagePlaintexts([
{
messageId,
recipientId,
encryptedData
}
], syncToken);
console.log(`Successfully uploaded plaintext for message ${messageId} to server`);
} catch (error) {
console.error("Failed to upload message plaintext to server:", error);
// Don't throw - message is already sent, plaintext upload failure shouldn't break anything
}
}
/**
* Fetch and decrypt message plaintexts from the server
* Called when loading message history
*/
export async function fetchMessagePlaintextsForRecipient(
recipientId: number
): Promise<Map<number, string>> {
const plaintexts = new Map<number, string>();
if (!syncToken || !syncUserId) {
console.warn("Message plaintext sync not initialized (missing token/userId)");
return plaintexts; // Return empty map if not initialized
}
try {
console.log(`Fetching encrypted plaintexts for recipient ${recipientId}...`);
const encryptedMessages = await fetchMessagePlaintexts(syncToken, recipientId);
console.log(`Found ${encryptedMessages.length} encrypted plaintexts, decrypting...`);
for (const msg of encryptedMessages) {
try {
// Use stored key if available, otherwise use password to derive it
const plaintext = await decryptMessagePlaintext(syncPassword, syncUserId, msg.encryptedData);
plaintexts.set(msg.messageId, plaintext);
} catch (error) {
console.warn(`Failed to decrypt plaintext for message ${msg.messageId}:`, error);
// Continue with other messages
}
}
console.log(`Decrypted ${plaintexts.size}/${encryptedMessages.length} plaintexts`);
} catch (error) {
console.error("Failed to fetch message plaintexts from server:", error);
// Don't throw - allow history loading to continue even if plaintext fetch fails
}
return plaintexts;
}
+49 -13
View File
@@ -1,37 +1,73 @@
/** /**
* Functions for encrypting/decrypting Signal Protocol session data * Functions for encrypting/decrypting Signal Protocol session data
* Uses password-derived key (same as backup encryption) * Uses a stable key derived from password (stored in localStorage as a hash)
*/ */
import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric"; import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric";
import { importPassword, deriveKEK, randomBytes } from "./kdf"; import { randomBytes } from "./kdf";
import { b64, ub64 } from "../utils"; import { b64, ub64 } from "../utils";
import { deriveSessionKey, exportKey, importKey, storeSessionKey, getStoredSessionKey } from "./sessionKeyStorage";
export interface EncryptedSessionData { export interface EncryptedSessionData {
salt: Uint8Array; // for PBKDF2 derivation of KEK salt: Uint8Array; // Random salt (kept for backward compatibility, not used for key derivation)
iv: Uint8Array; // AES-GCM IV iv: Uint8Array; // AES-GCM IV
ciphertext: Uint8Array; // encrypted session record (string) ciphertext: Uint8Array; // encrypted session record (string)
} }
/** /**
* Encrypt session record using password-derived key * Encrypt session record using stored session key (derived from password)
* If password is provided and key is not stored, derive and store it
* If password is not provided, use stored key (for page refresh scenarios)
*/ */
export async function encryptSessionWithPassword(password: string, sessionRecord: string): Promise<EncryptedSessionData> { export async function encryptSessionWithPassword(password: string | null, userId: string, sessionRecord: string): Promise<EncryptedSessionData> {
// Derive or get the stored session key
let sessionKey: CryptoKey;
const storedKeyString = getStoredSessionKey(userId);
if (storedKeyString) {
// Use stored key (works even without password on page refresh)
sessionKey = await importKey(storedKeyString);
} else if (password) {
// Derive new key and store it (as a "hash" - it's actually the derived key)
sessionKey = await deriveSessionKey(password, userId);
const keyString = await exportKey(sessionKey);
storeSessionKey(userId, keyString);
} else {
throw new Error("Cannot encrypt session: no stored key and no password provided");
}
// Generate random salt for backward compatibility (not used for key derivation)
const salt = randomBytes(16); const salt = randomBytes(16);
const pw = await importPassword(password);
const kek = await deriveKEK(pw, salt);
const sessionBytes = new TextEncoder().encode(sessionRecord); const sessionBytes = new TextEncoder().encode(sessionRecord);
const { iv, ciphertext } = await aesGcmEncrypt(kek, sessionBytes);
// Encrypt using the stable key (aesGcmEncrypt generates its own IV)
const { iv, ciphertext } = await aesGcmEncrypt(sessionKey, sessionBytes);
return { salt, iv, ciphertext }; return { salt, iv, ciphertext };
} }
/** /**
* Decrypt session record using password-derived key * Decrypt session record using stored session key
* If password is provided and key is not stored, derive and store it
* If password is not provided, use stored key (for page refresh scenarios)
*/ */
export async function decryptSessionWithPassword(password: string, blob: EncryptedSessionData): Promise<string> { export async function decryptSessionWithPassword(password: string | null, userId: string, blob: EncryptedSessionData): Promise<string> {
const pw = await importPassword(password); // Get or derive the session key
const kek = await deriveKEK(pw, blob.salt); let sessionKey: CryptoKey;
const plaintext = await aesGcmDecrypt(kek, blob.iv, blob.ciphertext); const storedKeyString = getStoredSessionKey(userId);
if (storedKeyString) {
// Use stored key (works even without password on page refresh)
sessionKey = await importKey(storedKeyString);
} else if (password) {
// Derive key from password and store it
sessionKey = await deriveSessionKey(password, userId);
const keyString = await exportKey(sessionKey);
storeSessionKey(userId, keyString);
} else {
throw new Error("Cannot decrypt session: no stored key and no password provided");
}
const plaintext = await aesGcmDecrypt(sessionKey, blob.iv, blob.ciphertext);
return new TextDecoder().decode(plaintext); return new TextDecoder().decode(plaintext);
} }
@@ -0,0 +1,89 @@
/**
* Functions for deriving and storing a stable key from password
* This key is used to encrypt/decrypt sessions on the server
*/
import { importPassword, deriveKEK } from "./kdf";
import { b64, ub64 } from "../utils";
const SESSION_KEY_SALT_PREFIX = "fromchat.session-key:";
/**
* Derive a stable key from password using user ID as salt
* User ID never changes, so this key will always be the same for a given password
* This key can be stored in localStorage and used to encrypt/decrypt sessions
*/
export async function deriveSessionKey(password: string, userId: string): Promise<CryptoKey> {
const salt = new TextEncoder().encode(`${SESSION_KEY_SALT_PREFIX}${userId}`);
const pw = await importPassword(password);
// Make the key extractable so we can store it in localStorage
return await deriveKEK(pw, salt, 210_000, true);
}
/**
* Export a CryptoKey to a base64 string for storage
*/
export async function exportKey(key: CryptoKey): Promise<string> {
const exported = await crypto.subtle.exportKey("raw", key);
return b64(new Uint8Array(exported));
}
/**
* Import a base64 string back to a CryptoKey
*/
export async function importKey(keyString: string): Promise<CryptoKey> {
const keyBytes = ub64(keyString);
// Ensure we have a proper ArrayBuffer (not SharedArrayBuffer)
// Create a new ArrayBuffer copy to avoid SharedArrayBuffer issues
const keyArray = new Uint8Array(keyBytes);
const keyBuffer = keyArray.buffer;
return await crypto.subtle.importKey(
"raw",
keyBuffer,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
/**
* Store the session key in localStorage
*/
export function storeSessionKey(userId: string, keyString: string): void {
try {
localStorage.setItem(`sessionKey:${userId}`, keyString);
console.log(`[SessionKeyStorage] ✅ Stored session key for user ${userId} (length: ${keyString.length})`);
} catch (error) {
console.error("[SessionKeyStorage] ❌ Failed to store session key:", error);
}
}
/**
* Retrieve the session key from localStorage
*/
export function getStoredSessionKey(userId: string): string | null {
try {
const key = localStorage.getItem(`sessionKey:${userId}`);
if (key) {
console.log(`[SessionKeyStorage] ✅ Retrieved stored session key for user ${userId} (length: ${key.length})`);
} else {
console.log(`[SessionKeyStorage] ⚠️ No stored session key found for user ${userId}`);
}
return key;
} catch (error) {
console.error("[SessionKeyStorage] ❌ Failed to get session key:", error);
return null;
}
}
/**
* Clear the session key from localStorage
*/
export function clearSessionKey(userId: string): void {
try {
localStorage.removeItem(`sessionKey:${userId}`);
} catch (error) {
console.error("Failed to clear session key:", error);
}
}
+19 -11
View File
@@ -41,7 +41,7 @@ export function initializeSessionSync(userId: string, password: string, token: s
} }
console.log(`Encrypting session for recipient ${recipientId}...`); console.log(`Encrypting session for recipient ${recipientId}...`);
const encryptedBlob = await encryptSessionWithPassword(syncPassword, record); const encryptedBlob = await encryptSessionWithPassword(syncPassword, syncUserId, record);
const encryptedData = encodeSessionBlob(encryptedBlob); const encryptedData = encodeSessionBlob(encryptedBlob);
console.log(`Uploading session for recipient ${recipientId} to server...`); console.log(`Uploading session for recipient ${recipientId} to server...`);
@@ -81,17 +81,20 @@ export async function restoreSessionsFromServer(
token: string token: string
): Promise<void> { ): Promise<void> {
try { try {
console.log("Restoring sessions from server..."); console.log("[Session Sync] Restoring sessions from server...");
console.log("[Session Sync] Making API request to fetch sessions...");
// Fetch encrypted sessions from server // Fetch encrypted sessions from server
const encryptedSessions = await fetchSessions(token); const encryptedSessions = await fetchSessions(token);
console.log(`[Session Sync] API response received: ${encryptedSessions.length} sessions found`);
if (encryptedSessions.length === 0) { if (encryptedSessions.length === 0) {
console.log("No sessions to restore from server"); console.log("[Session Sync] No sessions to restore from server");
return; // No sessions to restore return; // No sessions to restore
} }
console.log(`Found ${encryptedSessions.length} sessions on server, restoring...`); console.log(`[Session Sync] Found ${encryptedSessions.length} sessions on server, restoring...`);
const storage = new SignalProtocolStorage(userId); const storage = new SignalProtocolStorage(userId);
@@ -116,7 +119,8 @@ export async function restoreSessionsFromServer(
} }
const encryptedBlob = decodeSessionBlob(sessionData.encryptedData); const encryptedBlob = decodeSessionBlob(sessionData.encryptedData);
const sessionRecord = await decryptSessionWithPassword(password, encryptedBlob); // Use stored key if available, otherwise use password to derive it
const sessionRecord = await decryptSessionWithPassword(password, userId, encryptedBlob);
// Store in IndexedDB (sync callback won't fire because isRestoring is true) // Store in IndexedDB (sync callback won't fire because isRestoring is true)
await storage.storeSession(address, sessionRecord); await storage.storeSession(address, sessionRecord);
@@ -133,9 +137,13 @@ export async function restoreSessionsFromServer(
setRestoring(false); setRestoring(false);
} }
console.log(`Restored ${restoredCount}/${encryptedSessions.length} sessions from server${failedCount > 0 ? ` (${failedCount} failed)` : ""}`); console.log(`[Session Sync] Restored ${restoredCount}/${encryptedSessions.length} sessions from server${failedCount > 0 ? ` (${failedCount} failed)` : ""}`);
} catch (error) { } catch (error) {
console.error("Failed to restore sessions from server:", error); console.error("[Session Sync] Failed to restore sessions from server:", error);
console.error("[Session Sync] Error details:", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
// Don't throw - allow login to continue even if session restore fails // Don't throw - allow login to continue even if session restore fails
} }
} }
@@ -181,8 +189,8 @@ export async function uploadAllSessionsToServer(
continue; continue;
} }
// Encrypt session record // Encrypt session record (use stored key if available)
const encryptedBlob = await encryptSessionWithPassword(password, record); const encryptedBlob = await encryptSessionWithPassword(password, userId, record);
const encryptedData = encodeSessionBlob(encryptedBlob); const encryptedData = encodeSessionBlob(encryptedBlob);
sessionData.push({ sessionData.push({
@@ -235,9 +243,9 @@ export async function storeSessionWithSync(
return; return;
} }
// Encrypt and upload to server // Encrypt and upload to server (use stored key if available)
try { try {
const encryptedBlob = await encryptSessionWithPassword(password, record); const encryptedBlob = await encryptSessionWithPassword(password, userId, record);
const encryptedData = encodeSessionBlob(encryptedBlob); const encryptedData = encodeSessionBlob(encryptedBlob);
await uploadSessions([ await uploadSessions([
@@ -0,0 +1,135 @@
/**
* Centralized Signal Protocol initialization
* Used in login, register, and token restoration
*/
import { SignalProtocolService } from "./signalProtocol";
import { uploadAllPreKeys } from "@/core/api/crypto/prekeys";
import { initializeSessionSync, restoreSessionsFromServer, uploadAllSessionsToServer } from "./sessionSync";
import { initializeMessagePlaintextSync } from "./messagePlaintextSync";
import { deriveSessionKey, exportKey, storeSessionKey } from "./sessionKeyStorage";
export interface SignalProtocolInitOptions {
userId: string;
password: string;
token: string;
restoreSessions?: boolean;
uploadSessions?: boolean;
}
/**
* Initialize Signal Protocol with all necessary setup
* This function handles:
* - Signal Protocol service initialization
* - Session key derivation and storage
* - Session sync initialization
* - Message plaintext sync initialization
* - Prekey bundle upload
* - Session restoration from server (optional)
* - Plaintext restoration from server (optional)
* - Session upload to server (optional)
* - Plaintext upload to server (optional)
*/
export async function initializeSignalProtocol({
userId,
password,
token,
restoreSessions = false,
uploadSessions = false
}: SignalProtocolInitOptions): Promise<void> {
console.log("========================================");
console.log("[Signal Protocol Init] 🚀 STARTING SIGNAL PROTOCOL INITIALIZATION");
console.log("[Signal Protocol Init] User ID:", userId);
console.log("[Signal Protocol Init] Has password:", !!password);
console.log("[Signal Protocol Init] Has token:", !!token);
console.log("========================================");
try {
// Step 1: Derive and store session key
console.log("[Signal Protocol Init] Step 1: Deriving session key...");
const sessionKey = await deriveSessionKey(password, userId);
const keyString = await exportKey(sessionKey);
storeSessionKey(userId, keyString);
console.log("[Signal Protocol Init] Step 1: ✅ Session key derived and stored");
// Step 2: Initialize Signal Protocol service
console.log("[Signal Protocol Init] Step 2: Initializing Signal Protocol service...");
const signalService = new SignalProtocolService(userId);
await signalService.initialize();
console.log("[Signal Protocol Init] Step 2: ✅ Signal Protocol service initialized");
// Step 3: Initialize session sync
console.log("[Signal Protocol Init] Step 3: Initializing session sync...");
initializeSessionSync(userId, password, token);
console.log("[Signal Protocol Init] Step 3: ✅ Session sync initialized");
// Step 4: Initialize message plaintext sync
console.log("[Signal Protocol Init] Step 4: Initializing message plaintext sync...");
initializeMessagePlaintextSync(userId, password, token);
console.log("[Signal Protocol Init] Step 4: ✅ Message plaintext sync initialized");
// Step 5: Restore sessions from server (if requested)
if (restoreSessions) {
console.log("========================================");
console.log("[Signal Protocol Init] Step 5: ⚠️ RESTORING SESSIONS FROM SERVER");
console.log("========================================");
try {
await restoreSessionsFromServer(userId, password, token);
console.log("========================================");
console.log("[Signal Protocol Init] Step 5: ✅ SESSIONS RESTORED FROM SERVER");
console.log("========================================");
} catch (error) {
console.error("========================================");
console.error("[Signal Protocol Init] Step 5: ❌ SESSION RESTORATION FAILED");
console.error("[Signal Protocol Init] Error:", error);
console.error("========================================");
// Continue even if restoration fails
}
}
// Step 6: Upload prekey bundle
console.log("[Signal Protocol Init] Step 6: Uploading prekey bundle...");
const bundle = await signalService.getPreKeyBundle();
const { uploadPreKeyBundle } = await import("@/core/api/crypto/prekeys");
await uploadPreKeyBundle(bundle, token);
console.log("[Signal Protocol Init] Step 6: ✅ Prekey bundle uploaded");
// Step 7: Upload all prekeys
console.log("[Signal Protocol Init] Step 7: Uploading all prekeys...");
const baseBundle = await signalService.getBaseBundle();
const prekeys = await signalService.getAllPreKeys();
await uploadAllPreKeys(baseBundle, prekeys, token);
console.log(`[Signal Protocol Init] Step 7: ✅ Uploaded ${prekeys.length} prekeys to server`);
// Step 8: Upload all sessions to server (if requested)
if (uploadSessions) {
console.log("[Signal Protocol Init] Step 8: Uploading all sessions to server...");
try {
await uploadAllSessionsToServer(userId, password, token);
console.log("[Signal Protocol Init] Step 8: ✅ Sessions uploaded to server");
} catch (error) {
console.error("[Signal Protocol Init] Step 8: ❌ Failed to upload sessions:", error);
// Continue even if upload fails
}
}
// Step 9: Restore message plaintexts from server (if requested)
// Note: Message plaintext restoration is handled per-conversation when needed
// No bulk restoration needed here
// Step 10: Upload all message plaintexts to server (if requested)
// Note: Message plaintext upload is handled automatically when messages are sent
// No bulk upload needed here
console.log("========================================");
console.log("[Signal Protocol Init] ✅ ALL SIGNAL PROTOCOL INITIALIZATION COMPLETED");
console.log("========================================");
} catch (error) {
console.error("========================================");
console.error("[Signal Protocol Init] ❌ SIGNAL PROTOCOL INITIALIZATION FAILED");
console.error("[Signal Protocol Init] Error:", error);
console.error("========================================");
throw error;
}
}