mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement centralized Signal Protocol init
This commit is contained in:
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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)):
|
||||||
|
|||||||
@@ -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 || [];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -114,45 +114,30 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
|
|||||||
confirm_password: derived
|
confirm_password: derived
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
||||||
|
|
||||||
// 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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user