mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Try fixing initialization
This commit is contained in:
@@ -93,6 +93,19 @@ class SignalPreKey(Base):
|
|||||||
__table_args__ = (UniqueConstraint('user_id', 'prekey_id', name='_user_prekey_uc'),)
|
__table_args__ = (UniqueConstraint('user_id', 'prekey_id', name='_user_prekey_uc'),)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalSession(Base):
|
||||||
|
__tablename__ = "signal_session"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||||
|
recipient_id = Column(Integer, nullable=False, index=True) # The other user in the session
|
||||||
|
device_id = Column(Integer, default=1, nullable=False) # Device ID (always 1 for now)
|
||||||
|
encrypted_session_data = Column(Text, nullable=False) # Encrypted session record (JSON with salt, iv, ciphertext)
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint('user_id', 'recipient_id', 'device_id', name='_user_recipient_device_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
|
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession, SignalSession
|
||||||
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
|
||||||
@@ -707,6 +707,89 @@ def get_prekey_bundle_of(
|
|||||||
raise HTTPException(status_code=500, detail="Invalid bundle data")
|
raise HTTPException(status_code=500, detail="Invalid bundle data")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crypto/signal/sessions")
|
||||||
|
@rate_limit_per_ip("100/minute")
|
||||||
|
def upload_signal_sessions(
|
||||||
|
request: Request,
|
||||||
|
payload: dict,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Upload encrypted Signal Protocol sessions for the current user"""
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sessions = payload.get("sessions")
|
||||||
|
if not isinstance(sessions, list):
|
||||||
|
raise HTTPException(status_code=400, detail="sessions must be a list")
|
||||||
|
|
||||||
|
uploaded_count = 0
|
||||||
|
for session_data in sessions:
|
||||||
|
if not isinstance(session_data, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
recipient_id = session_data.get("recipientId")
|
||||||
|
device_id = session_data.get("deviceId", 1)
|
||||||
|
encrypted_data = session_data.get("encryptedData")
|
||||||
|
|
||||||
|
if 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 session
|
||||||
|
existing = db.query(SignalSession).filter(
|
||||||
|
SignalSession.user_id == current_user.id,
|
||||||
|
SignalSession.recipient_id == recipient_id,
|
||||||
|
SignalSession.device_id == device_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.encrypted_session_data = encrypted_data
|
||||||
|
existing.updated_at = datetime.now()
|
||||||
|
else:
|
||||||
|
new_session = SignalSession(
|
||||||
|
user_id=current_user.id,
|
||||||
|
recipient_id=recipient_id,
|
||||||
|
device_id=device_id,
|
||||||
|
encrypted_session_data=encrypted_data
|
||||||
|
)
|
||||||
|
db.add(new_session)
|
||||||
|
uploaded_count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok", "uploaded_count": uploaded_count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/crypto/signal/sessions")
|
||||||
|
@rate_limit_per_ip("60/minute")
|
||||||
|
def get_signal_sessions(
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get all encrypted Signal Protocol sessions for the current user"""
|
||||||
|
sessions = db.query(SignalSession).filter(
|
||||||
|
SignalSession.user_id == current_user.id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"recipientId": s.recipient_id,
|
||||||
|
"deviceId": s.device_id,
|
||||||
|
"encryptedData": s.encrypted_session_data,
|
||||||
|
"updatedAt": s.updated_at.isoformat()
|
||||||
|
}
|
||||||
|
for s in sessions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@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)):
|
||||||
|
|||||||
@@ -132,8 +132,17 @@ export async function decrypt(envelope: DmEnvelope, senderId: number): Promise<s
|
|||||||
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
|
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
|
try {
|
||||||
return plaintext;
|
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
|
||||||
|
return plaintext;
|
||||||
|
} catch (error) {
|
||||||
|
// If decryption fails, check if it's a session issue
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
if (errorMessage.includes("No session exists") || errorMessage.includes("No record for device")) {
|
||||||
|
console.warn(`Session missing for sender ${senderId} (envelope ID: ${envelope.id}). This may happen after page reload if the session was not properly restored.`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
||||||
@@ -158,7 +167,13 @@ export async function send(recipientId: number, plaintext: string, authToken: st
|
|||||||
const signalService = new SignalProtocolService(user.id.toString());
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
|
|
||||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||||
const hasSession = await signalService.hasSession(recipientId);
|
let hasSession = false;
|
||||||
|
try {
|
||||||
|
hasSession = await signalService.hasSession(recipientId);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to check session, will attempt to establish new one:", error);
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
try {
|
try {
|
||||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
||||||
@@ -168,13 +183,27 @@ export async function send(recipientId: number, plaintext: string, authToken: st
|
|||||||
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
|
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
// Log other errors for debugging
|
||||||
|
console.error("Failed to establish session:", {
|
||||||
|
recipientId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
// Re-throw other errors
|
// Re-throw other errors
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt with Signal Protocol
|
// Encrypt with Signal Protocol
|
||||||
const ciphertext = await signalService.encryptMessage(recipientId, plaintext);
|
let ciphertext: { type: number; body: string };
|
||||||
|
try {
|
||||||
|
ciphertext = await signalService.encryptMessage(recipientId, plaintext);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to encrypt message:", {
|
||||||
|
recipientId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// Verify the body is valid base64 before stringifying
|
// Verify the body is valid base64 before stringifying
|
||||||
if (ciphertext.body && typeof ciphertext.body === "string") {
|
if (ciphertext.body && typeof ciphertext.body === "string") {
|
||||||
@@ -214,7 +243,7 @@ export async function send(recipientId: number, plaintext: string, authToken: st
|
|||||||
|
|
||||||
// Add padding to obfuscate message size (anti-censorship)
|
// Add padding to obfuscate message size (anti-censorship)
|
||||||
const paddedCiphertext = addPadding(ciphertextJson);
|
const paddedCiphertext = addPadding(ciphertextJson);
|
||||||
|
|
||||||
const payload: SendDMRequest = {
|
const payload: SendDMRequest = {
|
||||||
recipientId: recipientId,
|
recipientId: recipientId,
|
||||||
iv: "", // Not used for Signal Protocol
|
iv: "", // Not used for Signal Protocol
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* API functions for managing Signal Protocol sessions on the server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/core/config";
|
||||||
|
import { getAuthHeaders } from "../user/auth";
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
recipientId: number;
|
||||||
|
deviceId: number;
|
||||||
|
encryptedData: string; // JSON string of encrypted session
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload encrypted Signal Protocol sessions to the server
|
||||||
|
*/
|
||||||
|
export async function uploadSessions(sessions: SessionData[], token: string): Promise<void> {
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
sessions
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to upload sessions: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all encrypted Signal Protocol sessions from the server
|
||||||
|
*/
|
||||||
|
export async function fetchSessions(token: string): Promise<SessionData[]> {
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, {
|
||||||
|
method: "GET",
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch sessions: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.sessions || [];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import * as userSearch from "./user/search";
|
|||||||
import * as cryptoPrekeys from "./crypto/prekeys";
|
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 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";
|
||||||
@@ -27,7 +28,8 @@ const api = {
|
|||||||
crypto: {
|
crypto: {
|
||||||
prekeys: cryptoPrekeys,
|
prekeys: cryptoPrekeys,
|
||||||
identity: cryptoIdentity,
|
identity: cryptoIdentity,
|
||||||
backup: cryptoBackup
|
backup: cryptoBackup,
|
||||||
|
sessions: cryptoSessions
|
||||||
},
|
},
|
||||||
moderation: {
|
moderation: {
|
||||||
blocklist: moderationBlocklist,
|
blocklist: moderationBlocklist,
|
||||||
|
|||||||
@@ -98,10 +98,22 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
|
|||||||
// Run Signal Protocol initialization in background to avoid blocking navigation
|
// Run Signal Protocol initialization in background to avoid blocking navigation
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
console.log("Starting Signal Protocol initialization...");
|
||||||
const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol");
|
const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol");
|
||||||
const { uploadPreKeyBundle, uploadAllPreKeys } = await import("@/core/api/crypto/prekeys");
|
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());
|
const signalService = new SignalProtocolService(data.user!.id.toString());
|
||||||
await signalService.initialize();
|
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)
|
// Upload base bundle with one prekey (for backward compatibility)
|
||||||
const bundle = await signalService.getPreKeyBundle();
|
const bundle = await signalService.getPreKeyBundle();
|
||||||
@@ -112,6 +124,9 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
|
|||||||
const prekeys = await signalService.getAllPreKeys();
|
const prekeys = await signalService.getAllPreKeys();
|
||||||
await uploadAllPreKeys(baseBundle, prekeys, data.token);
|
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(`Uploaded ${prekeys.length} prekeys to server`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Key setup failed:", e);
|
console.error("Key setup failed:", e);
|
||||||
|
|||||||
@@ -120,6 +120,40 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
|
|||||||
|
|
||||||
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)
|
||||||
|
if (data.user?.id) {
|
||||||
|
// Run Signal Protocol initialization in background to avoid blocking navigation
|
||||||
|
(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`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Key setup failed:", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Key setup failed:", e);
|
console.error("Key setup failed:", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,8 +166,21 @@ export function useDM() {
|
|||||||
|
|
||||||
for (const env of messages) {
|
for (const env of messages) {
|
||||||
try {
|
try {
|
||||||
const text = await decryptDm(env, env.senderId);
|
// Check if this is a message sent by the current user
|
||||||
const isAuthor = env.senderId !== userId;
|
const isAuthor = env.senderId === user.currentUser?.id;
|
||||||
|
let text: string;
|
||||||
|
|
||||||
|
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
|
||||||
|
} else {
|
||||||
|
// Decrypt incoming messages
|
||||||
|
text = await decryptDm(env, env.senderId);
|
||||||
|
}
|
||||||
|
|
||||||
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
|
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
|
||||||
|
|
||||||
decryptedMessages.push({
|
decryptedMessages.push({
|
||||||
|
|||||||
@@ -58,7 +58,20 @@ export class DMPanel extends MessagePanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
||||||
const plaintext = await decryptDm(env, env.senderId);
|
// Check if this is a message sent by the current user
|
||||||
|
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
|
||||||
|
|
||||||
|
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");
|
||||||
|
} else {
|
||||||
|
// Decrypt incoming messages
|
||||||
|
plaintext = await decryptDm(env, env.senderId);
|
||||||
|
}
|
||||||
|
|
||||||
const username = formatDMUsername(
|
const username = formatDMUsername(
|
||||||
env.senderId,
|
env.senderId,
|
||||||
env.recipientId,
|
env.recipientId,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { isElectron } from "@/core/electron/electron";
|
|||||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
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";
|
||||||
|
|
||||||
interface UserStore {
|
interface UserStore {
|
||||||
user: UserState;
|
user: UserState;
|
||||||
@@ -58,6 +59,9 @@ export const useUserStore = create<UserStore>((set) => ({
|
|||||||
typingManager.setAuthToken(null);
|
typingManager.setAuthToken(null);
|
||||||
onlineStatusManager.cleanup();
|
onlineStatusManager.cleanup();
|
||||||
typingManager.cleanup();
|
typingManager.cleanup();
|
||||||
|
|
||||||
|
// Clear session sync
|
||||||
|
clearSessionSync();
|
||||||
|
|
||||||
set({
|
set({
|
||||||
user: {
|
user: {
|
||||||
@@ -104,6 +108,22 @@ export const useUserStore = create<UserStore>((set) => ({
|
|||||||
onlineStatusManager.setAuthToken(token);
|
onlineStatusManager.setAuthToken(token);
|
||||||
typingManager.setAuthToken(token);
|
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
|
||||||
|
if (user.id) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol");
|
||||||
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
|
await signalService.initialize();
|
||||||
|
console.log("Signal Protocol initialized after restore (sessions will be re-established when needed)");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Signal Protocol initialization failed (restored):", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
// Ping will be sent automatically on WebSocket reconnect
|
// Ping will be sent automatically on WebSocket reconnect
|
||||||
// No need to send here to avoid duplicate pings
|
// No need to send here to avoid duplicate pings
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Functions for encrypting/decrypting Signal Protocol session data
|
||||||
|
* Uses password-derived key (same as backup encryption)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric";
|
||||||
|
import { importPassword, deriveKEK, randomBytes } from "./kdf";
|
||||||
|
import { b64, ub64 } from "../utils";
|
||||||
|
|
||||||
|
export interface EncryptedSessionData {
|
||||||
|
salt: Uint8Array; // for PBKDF2 derivation of KEK
|
||||||
|
iv: Uint8Array; // AES-GCM IV
|
||||||
|
ciphertext: Uint8Array; // encrypted session record (string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt session record using password-derived key
|
||||||
|
*/
|
||||||
|
export async function encryptSessionWithPassword(password: string, sessionRecord: string): Promise<EncryptedSessionData> {
|
||||||
|
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);
|
||||||
|
return { salt, iv, ciphertext };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt session record using password-derived key
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
return new TextDecoder().decode(plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode encrypted session data to JSON string for storage
|
||||||
|
*/
|
||||||
|
export function encodeSessionBlob(blob: EncryptedSessionData): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
salt: b64(blob.salt),
|
||||||
|
iv: b64(blob.iv),
|
||||||
|
ciphertext: b64(blob.ciphertext)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode encrypted session data from JSON string
|
||||||
|
*/
|
||||||
|
export function decodeSessionBlob(json: string): EncryptedSessionData {
|
||||||
|
const obj = JSON.parse(json);
|
||||||
|
return {
|
||||||
|
salt: ub64(obj.salt),
|
||||||
|
iv: ub64(obj.iv),
|
||||||
|
ciphertext: ub64(obj.ciphertext)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Service for syncing Signal Protocol sessions with the server
|
||||||
|
* Sessions are encrypted with password-derived key and stored on server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SignalProtocolStorage, setSessionSyncCallback, setRestoring } from "./signalStorage";
|
||||||
|
import { encryptSessionWithPassword, decryptSessionWithPassword, encodeSessionBlob, decodeSessionBlob } from "./sessionEncryption";
|
||||||
|
import { uploadSessions, fetchSessions, type SessionData } from "@/core/api/crypto/sessions";
|
||||||
|
|
||||||
|
// Global state for session sync
|
||||||
|
let syncPassword: string | null = null;
|
||||||
|
let syncToken: string | null = null;
|
||||||
|
let syncUserId: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize session sync - sets up automatic upload of sessions when they're created
|
||||||
|
* Called after login when password is available
|
||||||
|
*/
|
||||||
|
export function initializeSessionSync(userId: string, password: string, token: string): void {
|
||||||
|
console.log("Initializing session sync for user", userId);
|
||||||
|
syncUserId = userId;
|
||||||
|
syncPassword = password;
|
||||||
|
syncToken = token;
|
||||||
|
|
||||||
|
// Set up callback to upload sessions when they're stored
|
||||||
|
setSessionSyncCallback(async (address: string, record: string) => {
|
||||||
|
console.log(`Session sync callback invoked for address: ${address}`);
|
||||||
|
if (!syncPassword || !syncToken || !syncUserId) {
|
||||||
|
console.warn("Session sync not initialized (missing password/token/userId)");
|
||||||
|
return; // Not initialized yet
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = address.split(".");
|
||||||
|
const recipientId = parseInt(parts[0], 10);
|
||||||
|
const deviceId = parts.length > 1 ? parseInt(parts[1], 10) : 1;
|
||||||
|
|
||||||
|
if (isNaN(recipientId)) {
|
||||||
|
console.warn(`Invalid address format: ${address}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Encrypting session for recipient ${recipientId}...`);
|
||||||
|
const encryptedBlob = await encryptSessionWithPassword(syncPassword, record);
|
||||||
|
const encryptedData = encodeSessionBlob(encryptedBlob);
|
||||||
|
|
||||||
|
console.log(`Uploading session for recipient ${recipientId} to server...`);
|
||||||
|
await uploadSessions([
|
||||||
|
{
|
||||||
|
recipientId,
|
||||||
|
deviceId,
|
||||||
|
encryptedData
|
||||||
|
}
|
||||||
|
], syncToken);
|
||||||
|
console.log(`Successfully uploaded session for recipient ${recipientId} to server`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to sync session to server:", error);
|
||||||
|
// Don't throw - session is already stored in IndexedDB, sync failure shouldn't break anything
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log("Session sync callback set successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear session sync - called on logout
|
||||||
|
*/
|
||||||
|
export function clearSessionSync(): void {
|
||||||
|
syncUserId = null;
|
||||||
|
syncPassword = null;
|
||||||
|
syncToken = null;
|
||||||
|
setSessionSyncCallback(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore all sessions from server and populate IndexedDB
|
||||||
|
* Called after login when password is available
|
||||||
|
*/
|
||||||
|
export async function restoreSessionsFromServer(
|
||||||
|
userId: string,
|
||||||
|
password: string,
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log("Restoring sessions from server...");
|
||||||
|
|
||||||
|
// Fetch encrypted sessions from server
|
||||||
|
const encryptedSessions = await fetchSessions(token);
|
||||||
|
|
||||||
|
if (encryptedSessions.length === 0) {
|
||||||
|
console.log("No sessions to restore from server");
|
||||||
|
return; // No sessions to restore
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${encryptedSessions.length} sessions on server, restoring...`);
|
||||||
|
|
||||||
|
const storage = new SignalProtocolStorage(userId);
|
||||||
|
|
||||||
|
// Set restoring flag to prevent sync callback from re-uploading restored sessions
|
||||||
|
setRestoring(true);
|
||||||
|
|
||||||
|
let restoredCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decrypt and restore each session
|
||||||
|
for (const sessionData of encryptedSessions) {
|
||||||
|
try {
|
||||||
|
const address = `${sessionData.recipientId}.${sessionData.deviceId}`;
|
||||||
|
|
||||||
|
// Check if we already have a local session - if so, skip restoration
|
||||||
|
// This prevents overwriting a newer session with an older one
|
||||||
|
const existingSession = await storage.loadSession(address);
|
||||||
|
if (existingSession) {
|
||||||
|
console.log(`Skipping restoration for recipient ${sessionData.recipientId} - local session already exists`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedBlob = decodeSessionBlob(sessionData.encryptedData);
|
||||||
|
const sessionRecord = await decryptSessionWithPassword(password, encryptedBlob);
|
||||||
|
|
||||||
|
// Store in IndexedDB (sync callback won't fire because isRestoring is true)
|
||||||
|
await storage.storeSession(address, sessionRecord);
|
||||||
|
restoredCount++;
|
||||||
|
console.log(`Restored session for recipient ${sessionData.recipientId}`);
|
||||||
|
} catch (error) {
|
||||||
|
failedCount++;
|
||||||
|
console.warn(`Failed to restore session for recipient ${sessionData.recipientId}:`, error);
|
||||||
|
// Continue with other sessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Always clear the restoring flag
|
||||||
|
setRestoring(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Restored ${restoredCount}/${encryptedSessions.length} sessions from server${failedCount > 0 ? ` (${failedCount} failed)` : ""}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to restore sessions from server:", error);
|
||||||
|
// Don't throw - allow login to continue even if session restore fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload all sessions to server
|
||||||
|
* Called after login/registration to backup all current sessions
|
||||||
|
*/
|
||||||
|
export async function uploadAllSessionsToServer(
|
||||||
|
userId: string,
|
||||||
|
password: string,
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log("Uploading sessions to server...");
|
||||||
|
|
||||||
|
const storage = new SignalProtocolStorage(userId);
|
||||||
|
|
||||||
|
// Get all sessions from IndexedDB
|
||||||
|
const sessions = await storage.getAllSessions();
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
console.log("No sessions in IndexedDB to upload");
|
||||||
|
return; // No sessions to upload
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${sessions.length} sessions in IndexedDB, uploading...`);
|
||||||
|
|
||||||
|
// Encrypt and prepare sessions for upload
|
||||||
|
const sessionData: SessionData[] = [];
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
for (const { address, record } of sessions) {
|
||||||
|
try {
|
||||||
|
// Parse address to get recipientId and deviceId
|
||||||
|
const parts = address.split(".");
|
||||||
|
const recipientId = parseInt(parts[0], 10);
|
||||||
|
const deviceId = parts.length > 1 ? parseInt(parts[1], 10) : 1;
|
||||||
|
|
||||||
|
if (isNaN(recipientId)) {
|
||||||
|
console.warn(`Invalid address format: ${address}`);
|
||||||
|
failedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt session record
|
||||||
|
const encryptedBlob = await encryptSessionWithPassword(password, record);
|
||||||
|
const encryptedData = encodeSessionBlob(encryptedBlob);
|
||||||
|
|
||||||
|
sessionData.push({
|
||||||
|
recipientId,
|
||||||
|
deviceId,
|
||||||
|
encryptedData
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
failedCount++;
|
||||||
|
console.warn(`Failed to encrypt session ${address}:`, error);
|
||||||
|
// Continue with other sessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionData.length > 0) {
|
||||||
|
await uploadSessions(sessionData, token);
|
||||||
|
console.log(`Uploaded ${sessionData.length} sessions to server${failedCount > 0 ? ` (${failedCount} failed)` : ""}`);
|
||||||
|
} else if (failedCount > 0) {
|
||||||
|
console.warn(`Failed to upload all ${sessions.length} sessions to server`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to upload sessions to server:", error);
|
||||||
|
// Don't throw - allow login to continue even if upload fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a session in IndexedDB and upload to server
|
||||||
|
* This should be called instead of direct storage.storeSession when password is available
|
||||||
|
*/
|
||||||
|
export async function storeSessionWithSync(
|
||||||
|
userId: string,
|
||||||
|
address: string,
|
||||||
|
record: string,
|
||||||
|
password: string,
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
const storage = new SignalProtocolStorage(userId);
|
||||||
|
|
||||||
|
// Store in IndexedDB first (for immediate use)
|
||||||
|
await storage.storeSession(address, record);
|
||||||
|
|
||||||
|
// Parse address to get recipientId and deviceId
|
||||||
|
const parts = address.split(".");
|
||||||
|
const recipientId = parseInt(parts[0], 10);
|
||||||
|
const deviceId = parts.length > 1 ? parseInt(parts[1], 10) : 1;
|
||||||
|
|
||||||
|
if (isNaN(recipientId)) {
|
||||||
|
console.warn(`Invalid address format: ${address}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt and upload to server
|
||||||
|
try {
|
||||||
|
const encryptedBlob = await encryptSessionWithPassword(password, record);
|
||||||
|
const encryptedData = encodeSessionBlob(encryptedBlob);
|
||||||
|
|
||||||
|
await uploadSessions([
|
||||||
|
{
|
||||||
|
recipientId,
|
||||||
|
deviceId,
|
||||||
|
encryptedData
|
||||||
|
}
|
||||||
|
], token);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to upload session to server:", error);
|
||||||
|
// Don't throw - session is still stored in IndexedDB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a session from IndexedDB
|
||||||
|
* Note: We don't remove from server immediately because we need password for encryption.
|
||||||
|
* Stale sessions on server will be overwritten on next login when we upload all current sessions.
|
||||||
|
*/
|
||||||
|
export async function removeSessionLocal(
|
||||||
|
userId: string,
|
||||||
|
address: string
|
||||||
|
): Promise<void> {
|
||||||
|
const storage = new SignalProtocolStorage(userId);
|
||||||
|
await storage.removeSession(address);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -295,16 +295,17 @@ export class SignalProtocolService {
|
|||||||
* Encrypt a message for a recipient
|
* Encrypt a message for a recipient
|
||||||
*/
|
*/
|
||||||
async encryptMessage(recipientId: number, plaintext: string): Promise<{ type: number; body: string }> {
|
async encryptMessage(recipientId: number, plaintext: string): Promise<{ type: number; body: string }> {
|
||||||
const address = new SignalProtocolAddress(recipientId.toString(), 1);
|
try {
|
||||||
|
const address = new SignalProtocolAddress(recipientId.toString(), 1);
|
||||||
|
|
||||||
const sessionCipher = new SessionCipher(this.storage, address);
|
const sessionCipher = new SessionCipher(this.storage, address);
|
||||||
const plaintextBuffer = toArrayBuffer(new TextEncoder().encode(plaintext).buffer);
|
const plaintextBuffer = toArrayBuffer(new TextEncoder().encode(plaintext).buffer);
|
||||||
const encryptResult = await sessionCipher.encrypt(plaintextBuffer);
|
const encryptResult = await sessionCipher.encrypt(plaintextBuffer);
|
||||||
const { type, body } = encryptResult;
|
const { type, body } = encryptResult;
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
throw new Error("Encryption failed: no body in ciphertext");
|
throw new Error("Encryption failed: no body in ciphertext");
|
||||||
}
|
}
|
||||||
|
|
||||||
// The library returns body as ArrayBuffer or Uint8Array, we need to convert it to base64 string
|
// The library returns body as ArrayBuffer or Uint8Array, we need to convert it to base64 string
|
||||||
// Always convert to Uint8Array first, then to base64, regardless of input type
|
// Always convert to Uint8Array first, then to base64, regardless of input type
|
||||||
@@ -351,6 +352,16 @@ export class SignalProtocolService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { type, body: bodyBase64 };
|
return { type, body: bodyBase64 };
|
||||||
|
} catch (error) {
|
||||||
|
// Log detailed error information for debugging
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error("Signal Protocol encryption failed:", {
|
||||||
|
recipientId,
|
||||||
|
plaintextLength: plaintext.length,
|
||||||
|
error: errorMessage
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to encrypt message: ${errorMessage}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -412,8 +423,14 @@ export class SignalProtocolService {
|
|||||||
} catch (resetError) {
|
} catch (resetError) {
|
||||||
console.error("Failed to remove session:", resetError);
|
console.error("Failed to remove session:", resetError);
|
||||||
}
|
}
|
||||||
} else if (errorMessage.includes("Tried to decrypt on a sending chain") || errorMessage.includes("No record for device")) {
|
} else if (
|
||||||
// These errors indicate the session state is corrupted or missing
|
errorMessage.includes("Tried to decrypt on a sending chain") ||
|
||||||
|
errorMessage.includes("No record for device") ||
|
||||||
|
errorMessage.includes("Message key not found") ||
|
||||||
|
errorMessage.includes("counter was repeated") ||
|
||||||
|
errorMessage.includes("key was not filled")
|
||||||
|
) {
|
||||||
|
// These errors indicate the session state is corrupted, missing, or out of sync
|
||||||
// Remove the session so it can be re-established
|
// Remove the session so it can be re-established
|
||||||
console.warn(`Session state error for sender ${senderId}: ${errorMessage}. Removing session.`);
|
console.warn(`Session state error for sender ${senderId}: ${errorMessage}. Removing session.`);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ function openDB(): Promise<IDBDatabase> {
|
|||||||
db.createObjectStore("signedPreKeys", { keyPath: "userId" });
|
db.createObjectStore("signedPreKeys", { keyPath: "userId" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sessions store: key = userId + deviceId, value = { userId, deviceId, record }
|
// Sessions store: key = userId + recipientId, value = { userId, recipientId, deviceId, record }
|
||||||
|
// Note: recipientId is stored in the deviceId field for backward compatibility
|
||||||
|
// The actual deviceId is always 1 for now
|
||||||
if (!db.objectStoreNames.contains("sessions")) {
|
if (!db.objectStoreNames.contains("sessions")) {
|
||||||
const sessionsStore = db.createObjectStore("sessions", { keyPath: ["userId", "deviceId"] });
|
const sessionsStore = db.createObjectStore("sessions", { keyPath: ["userId", "deviceId"] });
|
||||||
sessionsStore.createIndex("userId", "userId", { unique: false });
|
sessionsStore.createIndex("userId", "userId", { unique: false });
|
||||||
@@ -115,6 +117,19 @@ function toUint8Array(ab: ArrayBuffer | Uint8Array): Uint8Array {
|
|||||||
return new Uint8Array(ab);
|
return new Uint8Array(ab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global session sync callback - set by sessionSync service
|
||||||
|
let sessionSyncCallback: ((address: string, record: string) => Promise<void>) | null = null;
|
||||||
|
// Flag to prevent sync callback during restoration (to avoid re-uploading restored sessions)
|
||||||
|
let isRestoring = false;
|
||||||
|
|
||||||
|
export function setSessionSyncCallback(callback: ((address: string, record: string) => Promise<void>) | null): void {
|
||||||
|
sessionSyncCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRestoring(restoring: boolean): void {
|
||||||
|
isRestoring = restoring;
|
||||||
|
}
|
||||||
|
|
||||||
export class SignalProtocolStorage implements StorageType {
|
export class SignalProtocolStorage implements StorageType {
|
||||||
private userId: string;
|
private userId: string;
|
||||||
|
|
||||||
@@ -322,51 +337,142 @@ export class SignalProtocolStorage implements StorageType {
|
|||||||
|
|
||||||
// Session Management
|
// Session Management
|
||||||
async loadSession(encodedAddress: string): Promise<string | undefined> {
|
async loadSession(encodedAddress: string): Promise<string | undefined> {
|
||||||
// encodedAddress format: "userId.deviceId"
|
// encodedAddress format: "recipientId.deviceId" (from Signal Protocol)
|
||||||
|
// recipientId is the other user's ID, deviceId is always 1 for now
|
||||||
const parts = encodedAddress.split(".");
|
const parts = encodedAddress.split(".");
|
||||||
const deviceId = parts.length > 1 ? parts[1] : encodedAddress;
|
const recipientId = parts[0]; // First part is the recipient's user ID
|
||||||
|
|
||||||
|
// Load using recipientId as the key (stored in deviceId field for backward compatibility)
|
||||||
|
// Ensure we search with string to match how we stored it
|
||||||
const store = await getStore("sessions");
|
const store = await getStore("sessions");
|
||||||
const result = await new Promise<string | undefined>((resolve, reject) => {
|
const result = await new Promise<string | undefined>((resolve, reject) => {
|
||||||
const request = store.get([this.userId, deviceId]);
|
const request = store.get([this.userId, String(recipientId)]);
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
const data = request.result;
|
const data = request.result;
|
||||||
resolve(data ? data.record : undefined);
|
if (data && data.record && typeof data.record === "string" && data.record.length > 0) {
|
||||||
|
resolve(data.record);
|
||||||
|
} else {
|
||||||
|
// Try with number if string didn't work (backward compatibility)
|
||||||
|
if (!data && !isNaN(Number(recipientId))) {
|
||||||
|
const numRequest = store.get([this.userId, Number(recipientId)]);
|
||||||
|
numRequest.onsuccess = () => {
|
||||||
|
const numData = numRequest.result;
|
||||||
|
if (numData && numData.record && typeof numData.record === "string" && numData.record.length > 0) {
|
||||||
|
resolve(numData.record);
|
||||||
|
} else {
|
||||||
|
console.warn(`Session record missing or invalid for recipient ${recipientId} (address: ${encodedAddress})`);
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
numRequest.onerror = () => {
|
||||||
|
console.warn(`Session record missing for recipient ${recipientId} (address: ${encodedAddress})`);
|
||||||
|
resolve(undefined);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.warn(`Session record missing or invalid for recipient ${recipientId} (address: ${encodedAddress})`);
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error(`Failed to load session for recipient ${recipientId}:`, request.error);
|
||||||
|
reject(request.error);
|
||||||
};
|
};
|
||||||
request.onerror = () => reject(request.error);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async storeSession(encodedAddress: string, record: string): Promise<void> {
|
async storeSession(encodedAddress: string, record: string): Promise<void> {
|
||||||
// encodedAddress format: "userId.deviceId"
|
// encodedAddress format: "recipientId.deviceId" (from Signal Protocol)
|
||||||
|
// recipientId is the other user's ID, deviceId is always 1 for now
|
||||||
const parts = encodedAddress.split(".");
|
const parts = encodedAddress.split(".");
|
||||||
const deviceId = parts.length > 1 ? parts[1] : encodedAddress;
|
const recipientId = parts[0]; // First part is the recipient's user ID
|
||||||
|
|
||||||
|
// Validate record
|
||||||
|
if (!record || typeof record !== "string" || record.length === 0) {
|
||||||
|
console.warn(`Invalid session record for address ${encodedAddress}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store with recipientId as the key (using deviceId field for backward compatibility)
|
||||||
|
// Ensure recipientId is stored as string to match how we load it
|
||||||
const store = await getStore("sessions", "readwrite");
|
const store = await getStore("sessions", "readwrite");
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const request = store.put({
|
const request = store.put({
|
||||||
userId: this.userId,
|
userId: this.userId,
|
||||||
deviceId: deviceId,
|
deviceId: String(recipientId), // Store recipientId as string in deviceId field
|
||||||
record: record
|
record: record
|
||||||
});
|
});
|
||||||
request.onsuccess = () => resolve();
|
request.onsuccess = () => {
|
||||||
|
resolve();
|
||||||
|
// If session sync callback is set and we're not restoring, upload to server in background (non-blocking)
|
||||||
|
// Do this AFTER resolve() to ensure storage completes even if sync fails
|
||||||
|
if (sessionSyncCallback && !isRestoring) {
|
||||||
|
// Use setTimeout to make it truly async and non-blocking
|
||||||
|
setTimeout(() => {
|
||||||
|
sessionSyncCallback!(encodedAddress, record).then(() => {
|
||||||
|
console.log(`Session synced to server for ${encodedAddress}`);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(`Failed to sync session to server for ${encodedAddress}:`, err);
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
request.onerror = () => reject(request.error);
|
request.onerror = () => reject(request.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeSession(encodedAddress: string): Promise<void> {
|
async removeSession(encodedAddress: string): Promise<void> {
|
||||||
// encodedAddress format: "userId.deviceId"
|
// encodedAddress format: "recipientId.deviceId" (from Signal Protocol)
|
||||||
|
// recipientId is the other user's ID, deviceId is always 1 for now
|
||||||
const parts = encodedAddress.split(".");
|
const parts = encodedAddress.split(".");
|
||||||
const deviceId = parts.length > 1 ? parts[1] : encodedAddress;
|
const recipientId = parts[0]; // First part is the recipient's user ID
|
||||||
|
|
||||||
|
// Remove using recipientId as the key (stored in deviceId field for backward compatibility)
|
||||||
const store = await getStore("sessions", "readwrite");
|
const store = await getStore("sessions", "readwrite");
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const request = store.delete([this.userId, deviceId]);
|
const request = store.delete([this.userId, recipientId]);
|
||||||
request.onsuccess = () => resolve();
|
request.onsuccess = () => resolve();
|
||||||
request.onerror = () => reject(request.error);
|
request.onerror = () => reject(request.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all sessions for this user
|
||||||
|
* Returns array of { address, record } where address is "recipientId.deviceId"
|
||||||
|
* Note: In IndexedDB, deviceId field actually stores the recipientId from the Signal Protocol address
|
||||||
|
*/
|
||||||
|
async getAllSessions(): Promise<Array<{ address: string; record: string }>> {
|
||||||
|
const store = await getStore("sessions");
|
||||||
|
const sessions: Array<{ address: string; record: string }> = [];
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.index("userId").openCursor(IDBKeyRange.only(this.userId));
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const cursor = request.result;
|
||||||
|
if (cursor) {
|
||||||
|
const data = cursor.value;
|
||||||
|
// In Signal Protocol, address format is "recipientId.deviceId"
|
||||||
|
// We stored it with recipientId in the deviceId field (for backward compatibility)
|
||||||
|
// The actual deviceId is always 1 for now
|
||||||
|
const recipientId = data.deviceId; // This is actually the recipientId from the address
|
||||||
|
const deviceId = 1; // Always 1 for now
|
||||||
|
const address = `${recipientId}.${deviceId}`;
|
||||||
|
|
||||||
|
// Validate that record exists and is a string
|
||||||
|
if (data.record && typeof data.record === "string" && data.record.length > 0) {
|
||||||
|
sessions.push({ address, record: data.record });
|
||||||
|
} else {
|
||||||
|
console.warn(`Invalid session record for recipient ${recipientId}:`, data);
|
||||||
|
}
|
||||||
|
cursor.continue();
|
||||||
|
} else {
|
||||||
|
resolve(sessions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user