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'),)
|
||||
|
||||
|
||||
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):
|
||||
__tablename__ = "dm_envelope"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from constants import OWNER_USERNAME
|
||||
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 validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||
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")
|
||||
@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)):
|
||||
|
||||
@@ -262,6 +262,9 @@ export async function send(recipientId: number, plaintext: string, authToken: st
|
||||
},
|
||||
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> {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
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 res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, {
|
||||
@@ -43,11 +46,24 @@ export async function fetchSessions(token: string): Promise<SessionData[]> {
|
||||
headers
|
||||
});
|
||||
|
||||
console.log("[Session API] Response status:", res.status, res.statusText);
|
||||
|
||||
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();
|
||||
console.log("[Session API] Response data:", {
|
||||
hasSessions: !!data.sessions,
|
||||
sessionCount: data.sessions?.length || 0
|
||||
});
|
||||
|
||||
return data.sessions || [];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as cryptoPrekeys from "./crypto/prekeys";
|
||||
import * as cryptoIdentity from "./crypto/identity";
|
||||
import * as cryptoBackup from "./crypto/backup";
|
||||
import * as cryptoSessions from "./crypto/sessions";
|
||||
import * as cryptoMessagePlaintexts from "./crypto/messagePlaintexts";
|
||||
import * as moderationBlocklist from "./moderation/blocklist";
|
||||
import * as moderationUsers from "./moderation/users";
|
||||
import * as callsModule from "./calls";
|
||||
@@ -29,7 +30,8 @@ const api = {
|
||||
prekeys: cryptoPrekeys,
|
||||
identity: cryptoIdentity,
|
||||
backup: cryptoBackup,
|
||||
sessions: cryptoSessions
|
||||
sessions: cryptoSessions,
|
||||
messagePlaintexts: cryptoMessagePlaintexts
|
||||
},
|
||||
moderation: {
|
||||
blocklist: moderationBlocklist,
|
||||
|
||||
@@ -79,7 +79,14 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
console.log("========================================");
|
||||
console.log("[LoginForm] 🚀 LOGIN FORM SUBMITTED");
|
||||
console.log("[LoginForm] Username:", username);
|
||||
console.log("[LoginForm] Has password:", !!password);
|
||||
console.log("========================================");
|
||||
|
||||
try {
|
||||
console.log("[LoginForm] Deriving auth secret...");
|
||||
const derived = await api.user.auth.deriveAuthSecret(username, password);
|
||||
const request: LoginRequest = {
|
||||
username: username,
|
||||
@@ -87,54 +94,52 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[LoginForm] Calling login API...");
|
||||
const data = await api.user.auth.login(request);
|
||||
console.log("[LoginForm] Login successful, user ID:", data.user?.id);
|
||||
|
||||
setUser(data.token, data.user);
|
||||
|
||||
try {
|
||||
console.log("[LoginForm] Ensuring keys on login...");
|
||||
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
||||
console.log("[LoginForm] Keys ensured");
|
||||
|
||||
// Initialize Signal Protocol after keys are set up (non-blocking)
|
||||
if (data.user?.id) {
|
||||
console.log("[LoginForm] ✅ User ID exists, scheduling Signal Protocol initialization");
|
||||
// Run Signal Protocol initialization in background to avoid blocking navigation
|
||||
(async () => {
|
||||
// Use setTimeout to ensure it runs even if navigation happens
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
console.log("Starting Signal Protocol initialization...");
|
||||
const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol");
|
||||
const { uploadPreKeyBundle, uploadAllPreKeys } = await import("@/core/api/crypto/prekeys");
|
||||
const { restoreSessionsFromServer, uploadAllSessionsToServer, initializeSessionSync } = await import("@/utils/crypto/sessionSync");
|
||||
|
||||
const signalService = new SignalProtocolService(data.user!.id.toString());
|
||||
await signalService.initialize();
|
||||
console.log("Signal Protocol initialized");
|
||||
|
||||
// Initialize session sync (enables automatic upload of new sessions)
|
||||
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`);
|
||||
console.log("[LoginForm] 🚀 Starting Signal Protocol initialization...");
|
||||
const { initializeSignalProtocol } = await import("@/utils/crypto/signalProtocolInit");
|
||||
await initializeSignalProtocol({
|
||||
userId: data.user!.id.toString(),
|
||||
password,
|
||||
token: data.token,
|
||||
restoreSessions: true,
|
||||
uploadSessions: true
|
||||
});
|
||||
console.log("[LoginForm] ✅ Signal Protocol initialization completed");
|
||||
} 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) {
|
||||
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
|
||||
|
||||
@@ -114,45 +114,30 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
|
||||
confirm_password: derived
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.user.auth.register(request);
|
||||
setUser(data.token, data.user);
|
||||
try {
|
||||
const data = await api.user.auth.register(request);
|
||||
|
||||
setUser(data.token, data.user);
|
||||
|
||||
try {
|
||||
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
||||
|
||||
// Initialize Signal Protocol after keys are set up (non-blocking)
|
||||
if (data.user?.id) {
|
||||
// Run Signal Protocol initialization in background to avoid blocking navigation
|
||||
(async () => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol");
|
||||
const { uploadPreKeyBundle, uploadAllPreKeys } = await import("@/core/api/crypto/prekeys");
|
||||
const { uploadAllSessionsToServer, initializeSessionSync } = await import("@/utils/crypto/sessionSync");
|
||||
|
||||
const signalService = new SignalProtocolService(data.user!.id.toString());
|
||||
await signalService.initialize();
|
||||
|
||||
// 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`);
|
||||
const { initializeSignalProtocol } = await import("@/utils/crypto/signalProtocolInit");
|
||||
await initializeSignalProtocol({
|
||||
userId: data.user!.id.toString(),
|
||||
password,
|
||||
token: data.token,
|
||||
restoreSessions: false,
|
||||
uploadSessions: true
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
console.error("[RegisterForm] Signal Protocol initialization failed:", e);
|
||||
}
|
||||
})();
|
||||
}, 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
|
||||
@@ -172,10 +172,16 @@ export function useDM() {
|
||||
|
||||
if (isAuthor) {
|
||||
// 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
|
||||
// or try to get it from the envelope if available
|
||||
// Skip this message for now - we'll need to store plaintext when sending
|
||||
continue; // Skip sent messages - they'll be handled by the send flow
|
||||
// Try to get the plaintext from the server (encrypted)
|
||||
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||
const plaintexts = await fetchMessagePlaintextsForRecipient(userId);
|
||||
const cached = plaintexts.get(env.id);
|
||||
if (cached) {
|
||||
text = cached;
|
||||
} else {
|
||||
// Not on server - skip this message
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Decrypt incoming messages
|
||||
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 { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
import { typingManager } from "@/core/typingManager";
|
||||
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||
|
||||
export interface DMPanelData {
|
||||
userId: number;
|
||||
@@ -18,11 +19,16 @@ export interface DMPanelData {
|
||||
export class DMPanel extends MessagePanel {
|
||||
public dmData: DMPanelData | null = null;
|
||||
private messagesLoaded: boolean = false;
|
||||
private signalService: SignalProtocolService | null = null;
|
||||
|
||||
constructor(
|
||||
user: UserState
|
||||
) {
|
||||
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 {
|
||||
@@ -64,9 +70,9 @@ export class DMPanel extends MessagePanel {
|
||||
let plaintext: string;
|
||||
if (isSentByUs) {
|
||||
// Can't decrypt our own sent messages in Signal Protocol
|
||||
// The plaintext should be stored when sending, but for now we'll skip it
|
||||
// This message should have been displayed immediately when sent
|
||||
throw new Error("Cannot decrypt own sent message - should be displayed from send flow");
|
||||
// The plaintext should be passed in from loadMessages (fetched from server)
|
||||
// For now, throw an error - the caller should handle this by fetching plaintexts first
|
||||
throw new Error("Cannot decrypt own sent message - plaintext must be fetched from server first");
|
||||
} else {
|
||||
// Decrypt incoming messages
|
||||
plaintext = await decryptDm(env, env.senderId);
|
||||
@@ -119,6 +125,28 @@ export class DMPanel extends MessagePanel {
|
||||
|
||||
this.setLoading(true);
|
||||
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 { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit);
|
||||
const decryptedMessages: Message[] = [];
|
||||
@@ -130,7 +158,53 @@ export class DMPanel extends MessagePanel {
|
||||
if (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);
|
||||
|
||||
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
|
||||
@@ -177,6 +251,28 @@ export class DMPanel extends MessagePanel {
|
||||
|
||||
this.setLoadingMore(true);
|
||||
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 { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages(
|
||||
this.dmData.userId,
|
||||
@@ -189,7 +285,57 @@ export class DMPanel extends MessagePanel {
|
||||
const decryptedMessages: Message[] = [];
|
||||
for (const env of newEnvelopes) {
|
||||
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);
|
||||
} catch (error) {
|
||||
// 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");
|
||||
if (error instanceof PrekeyExhaustedError) {
|
||||
const { alert } = await import("@/core/components/AlertDialog");
|
||||
await alert({
|
||||
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."
|
||||
});
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,12 +441,27 @@ export class DMPanel extends MessagePanel {
|
||||
if (isOurMessage) {
|
||||
// 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);
|
||||
let tempMsgContent: string | null = null;
|
||||
for (const tempMsg of tempMessages) {
|
||||
if (tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content) {
|
||||
tempMsgContent = tempMsg.content; // Get plaintext from temp message
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
import { typingManager } from "@/core/typingManager";
|
||||
import type { UserState } from "./types";
|
||||
import { clearSessionSync } from "@/utils/crypto/sessionSync";
|
||||
import { clearMessagePlaintextSync } from "@/utils/crypto/messagePlaintextSync";
|
||||
|
||||
interface UserStore {
|
||||
user: UserState;
|
||||
@@ -51,8 +52,9 @@ export const useUserStore = create<UserStore>((set) => ({
|
||||
try {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('currentUser');
|
||||
sessionStorage.removeItem('sessionPassword');
|
||||
} catch (error) {
|
||||
console.error('Failed to clear localStorage:', error);
|
||||
console.error('Failed to clear storage:', error);
|
||||
}
|
||||
|
||||
onlineStatusManager.setAuthToken(null);
|
||||
@@ -62,6 +64,7 @@ export const useUserStore = create<UserStore>((set) => ({
|
||||
|
||||
// Clear session sync
|
||||
clearSessionSync();
|
||||
clearMessagePlaintextSync();
|
||||
|
||||
set({
|
||||
user: {
|
||||
@@ -109,17 +112,47 @@ export const useUserStore = create<UserStore>((set) => ({
|
||||
typingManager.setAuthToken(token);
|
||||
|
||||
// Initialize Signal Protocol after restoring user (non-blocking)
|
||||
// Note: We can't restore sessions without the password, but we can initialize
|
||||
// Signal Protocol so new sessions can be created when needed
|
||||
// Note: We can't restore sessions without password, but we can initialize Signal Protocol
|
||||
if (user.id) {
|
||||
(async () => {
|
||||
try {
|
||||
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());
|
||||
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) {
|
||||
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"]);
|
||||
}
|
||||
|
||||
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;
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
|
||||
passwordKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
extractable,
|
||||
["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
|
||||
* 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 { importPassword, deriveKEK, randomBytes } from "./kdf";
|
||||
import { randomBytes } from "./kdf";
|
||||
import { b64, ub64 } from "../utils";
|
||||
import { deriveSessionKey, exportKey, importKey, storeSessionKey, getStoredSessionKey } from "./sessionKeyStorage";
|
||||
|
||||
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
|
||||
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 pw = await importPassword(password);
|
||||
const kek = await deriveKEK(pw, salt);
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const pw = await importPassword(password);
|
||||
const kek = await deriveKEK(pw, blob.salt);
|
||||
const plaintext = await aesGcmDecrypt(kek, blob.iv, blob.ciphertext);
|
||||
export async function decryptSessionWithPassword(password: string | null, userId: string, blob: EncryptedSessionData): Promise<string> {
|
||||
// Get or derive the 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 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}...`);
|
||||
const encryptedBlob = await encryptSessionWithPassword(syncPassword, record);
|
||||
const encryptedBlob = await encryptSessionWithPassword(syncPassword, syncUserId, record);
|
||||
const encryptedData = encodeSessionBlob(encryptedBlob);
|
||||
|
||||
console.log(`Uploading session for recipient ${recipientId} to server...`);
|
||||
@@ -81,17 +81,20 @@ export async function restoreSessionsFromServer(
|
||||
token: string
|
||||
): Promise<void> {
|
||||
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
|
||||
const encryptedSessions = await fetchSessions(token);
|
||||
|
||||
console.log(`[Session Sync] API response received: ${encryptedSessions.length} sessions found`);
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -116,7 +119,8 @@ export async function restoreSessionsFromServer(
|
||||
}
|
||||
|
||||
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)
|
||||
await storage.storeSession(address, sessionRecord);
|
||||
@@ -133,9 +137,13 @@ export async function restoreSessionsFromServer(
|
||||
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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -181,8 +189,8 @@ export async function uploadAllSessionsToServer(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Encrypt session record
|
||||
const encryptedBlob = await encryptSessionWithPassword(password, record);
|
||||
// Encrypt session record (use stored key if available)
|
||||
const encryptedBlob = await encryptSessionWithPassword(password, userId, record);
|
||||
const encryptedData = encodeSessionBlob(encryptedBlob);
|
||||
|
||||
sessionData.push({
|
||||
@@ -235,9 +243,9 @@ export async function storeSessionWithSync(
|
||||
return;
|
||||
}
|
||||
|
||||
// Encrypt and upload to server
|
||||
// Encrypt and upload to server (use stored key if available)
|
||||
try {
|
||||
const encryptedBlob = await encryptSessionWithPassword(password, record);
|
||||
const encryptedBlob = await encryptSessionWithPassword(password, userId, record);
|
||||
const encryptedData = encodeSessionBlob(encryptedBlob);
|
||||
|
||||
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