Try fixing initialization

This commit is contained in:
2025-12-03 18:43:16 +03:00
Unverified
parent 10e5417254
commit 4b5de53900
14 changed files with 759 additions and 33 deletions
+13
View File
@@ -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"
+84 -1
View File
@@ -10,7 +10,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
from dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession 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)):
+31 -2
View File
@@ -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)}`);
} }
try {
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext); const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
return plaintext; 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") {
+53
View File
@@ -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 || [];
}
+3 -1
View File
@@ -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,
+15
View File
@@ -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);
+34
View File
@@ -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);
} }
+15 -2
View File
@@ -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,
+20
View File
@@ -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;
@@ -59,6 +60,9 @@ export const useUserStore = create<UserStore>((set) => ({
onlineStatusManager.cleanup(); onlineStatusManager.cleanup();
typingManager.cleanup(); typingManager.cleanup();
// Clear session sync
clearSessionSync();
set({ set({
user: { user: {
currentUser: null, currentUser: null,
@@ -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)
};
}
+268
View File
@@ -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);
}
+19 -2
View File
@@ -295,6 +295,7 @@ 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 }> {
try {
const address = new SignalProtocolAddress(recipientId.toString(), 1); const address = new SignalProtocolAddress(recipientId.toString(), 1);
const sessionCipher = new SessionCipher(this.storage, address); const sessionCipher = new SessionCipher(this.storage, address);
@@ -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 {
+119 -13
View File
@@ -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);
});
}
} }