Remove SyncService and introduce context-based offline mode and state management
- Delete `SyncService` and its associated bidirectional synchronization logic. - Add multiple context providers (`OfflineProvider`, `AlertProvider`, `LangContext`, `UserContext`, `SessionContext`, `WorldContext`, `SettingBookContext`) for contextual state management. - Implement `SecureStorage` for OS-level secure data encryption and replace dependency on `SyncService` synchronization. - Update localization files (`en.json`, `fr.json`) with offline mode and error-related strings.
This commit is contained in:
150
electron/ipc/offline.ipc.ts
Normal file
150
electron/ipc/offline.ipc.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { createHandler } from '../database/LocalSystem.js';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { getSecureStorage } from '../storage/SecureStorage.js';
|
||||
|
||||
interface SetPinData {
|
||||
pin: string;
|
||||
}
|
||||
|
||||
interface VerifyPinData {
|
||||
pin: string;
|
||||
}
|
||||
|
||||
interface OfflineModeData {
|
||||
enabled: boolean;
|
||||
syncInterval?: number; // days
|
||||
}
|
||||
|
||||
ipcMain.handle('offline:pin:set', async (_event, data: SetPinData) => {
|
||||
try {
|
||||
const storage = getSecureStorage();
|
||||
const userId = storage.get<string>('userId');
|
||||
|
||||
if (!userId) {
|
||||
return { success: false, error: 'No user logged in' };
|
||||
}
|
||||
|
||||
// Hash the PIN
|
||||
const hashedPin = await bcrypt.hash(data.pin, 10);
|
||||
|
||||
// Store hashed PIN
|
||||
storage.set(`pin-${userId}`, hashedPin);
|
||||
storage.save();
|
||||
|
||||
console.log('[Offline] PIN set for user');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Offline] Error setting PIN:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
// Verify PIN for offline access
|
||||
ipcMain.handle('offline:pin:verify', async (_event, data: VerifyPinData) => {
|
||||
try {
|
||||
const storage = getSecureStorage();
|
||||
|
||||
// Try to get last known userId
|
||||
const lastUserId = storage.get<string>('lastUserId');
|
||||
if (!lastUserId) {
|
||||
return { success: false, error: 'No offline account found' };
|
||||
}
|
||||
|
||||
const hashedPin = storage.get<string>(`pin-${lastUserId}`);
|
||||
if (!hashedPin) {
|
||||
return { success: false, error: 'No PIN configured' };
|
||||
}
|
||||
|
||||
// Verify PIN
|
||||
const isValid = await bcrypt.compare(data.pin, hashedPin);
|
||||
|
||||
if (isValid) {
|
||||
// Set userId for session
|
||||
storage.set('userId', lastUserId);
|
||||
console.log('[Offline] PIN verified, user authenticated locally');
|
||||
return {
|
||||
success: true,
|
||||
userId: lastUserId
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: 'Invalid PIN' };
|
||||
} catch (error) {
|
||||
console.error('[Offline] Error verifying PIN:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
// Set offline mode preference
|
||||
ipcMain.handle('offline:mode:set', (_event, data: OfflineModeData) => {
|
||||
try {
|
||||
const storage = getSecureStorage();
|
||||
storage.set('offlineMode', data.enabled);
|
||||
|
||||
if (data.syncInterval) {
|
||||
storage.set('syncInterval', data.syncInterval);
|
||||
}
|
||||
|
||||
storage.save();
|
||||
console.log('[Offline] Mode set to:', data.enabled);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Offline] Error setting mode:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
// Get offline mode status
|
||||
ipcMain.handle('offline:mode:get', () => {
|
||||
try {
|
||||
const storage = getSecureStorage();
|
||||
const offlineMode = storage.get<boolean>('offlineMode', false);
|
||||
const syncInterval = storage.get<number>('syncInterval', 30);
|
||||
const lastUserId = storage.get<string>('lastUserId');
|
||||
const hasPin = lastUserId ? !!storage.get<string>(`pin-${lastUserId}`) : false;
|
||||
|
||||
return {
|
||||
enabled: offlineMode,
|
||||
syncInterval,
|
||||
hasPin,
|
||||
lastUserId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Offline] Error getting mode:', error);
|
||||
return {
|
||||
enabled: false,
|
||||
syncInterval: 30,
|
||||
hasPin: false
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Check if should sync
|
||||
ipcMain.handle('offline:sync:check', () => {
|
||||
try {
|
||||
const storage = getSecureStorage();
|
||||
const lastSync = storage.get<string>('lastSync');
|
||||
const syncInterval = storage.get<number>('syncInterval', 30) || 30;
|
||||
|
||||
if (!lastSync) {
|
||||
return { shouldSync: true };
|
||||
}
|
||||
|
||||
const daysSinceSync = Math.floor(
|
||||
(Date.now() - new Date(lastSync).getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
return {
|
||||
shouldSync: daysSinceSync >= syncInterval,
|
||||
daysSinceSync,
|
||||
syncInterval
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Offline] Error checking sync:', error);
|
||||
return { shouldSync: false };
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[IPC] Offline handlers registered');
|
||||
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'url';
|
||||
import * as fs from 'fs';
|
||||
import { getDatabaseService } from './database/database.service.js';
|
||||
import { getSecureStorage } from './storage/SecureStorage.js';
|
||||
import { getUserEncryptionKey, setUserEncryptionKey, hasUserEncryptionKey } from './database/keyManager.js';
|
||||
import { generateUserEncryptionKey } from './database/encryption.js';
|
||||
|
||||
// Import IPC handlers
|
||||
import './ipc/book.ipc.js';
|
||||
@@ -120,7 +122,10 @@ function createMainWindow(): void {
|
||||
// IPC Handlers pour la gestion du token (OS-encrypted storage)
|
||||
ipcMain.handle('get-token', () => {
|
||||
const storage = getSecureStorage();
|
||||
return storage.get('authToken', null);
|
||||
const token = storage.get('authToken', null);
|
||||
console.log('[GetToken] Token requested, exists:', !!token);
|
||||
console.log('[GetToken] Storage has authToken:', storage.has('authToken'));
|
||||
return token;
|
||||
});
|
||||
|
||||
ipcMain.handle('set-token', (_event, token: string) => {
|
||||
@@ -156,12 +161,9 @@ ipcMain.handle('init-user', async (_event, userId: string) => {
|
||||
storage.set('lastUserId', userId); // Save for offline mode
|
||||
|
||||
try {
|
||||
const { getUserEncryptionKey, setUserEncryptionKey, hasUserEncryptionKey } = await import('./database/keyManager.js');
|
||||
|
||||
let encryptionKey: string | null = null;
|
||||
|
||||
if (!hasUserEncryptionKey(userId)) {
|
||||
const { generateUserEncryptionKey } = await import('./database/encryption.js');
|
||||
encryptionKey = generateUserEncryptionKey(userId);
|
||||
|
||||
console.log('[InitUser] Generated new encryption key for user');
|
||||
@@ -214,8 +216,10 @@ ipcMain.handle('init-user', async (_event, userId: string) => {
|
||||
});
|
||||
|
||||
ipcMain.on('login-success', async (_event, token: string) => {
|
||||
console.log('[Login] Received token, setting in storage');
|
||||
const storage = getSecureStorage();
|
||||
storage.set('authToken', token);
|
||||
console.log('[Login] Token set in cache, has authToken:', storage.has('authToken'));
|
||||
// Note: userId will be set later when we get user info from server
|
||||
|
||||
if (loginWindow) {
|
||||
@@ -298,7 +302,6 @@ interface SyncUserData {
|
||||
|
||||
ipcMain.handle('db:user:sync', async (_event, data: SyncUserData): Promise<boolean> => {
|
||||
try {
|
||||
// Import User models dynamically to avoid circular dependencies
|
||||
const { default: User } = await import('./database/models/User.js');
|
||||
const { default: UserRepo } = await import('./database/repositories/user.repository.js');
|
||||
|
||||
@@ -339,8 +342,6 @@ ipcMain.handle('db:user:sync', async (_event, data: SyncUserData): Promise<boole
|
||||
*/
|
||||
ipcMain.handle('generate-encryption-key', async (_event, userId: string) => {
|
||||
try {
|
||||
// Import encryption module dynamically
|
||||
const { generateUserEncryptionKey } = await import('./database/encryption.js');
|
||||
const key = generateUserEncryptionKey(userId);
|
||||
return { success: true, key };
|
||||
} catch (error) {
|
||||
@@ -453,43 +454,8 @@ app.whenReady().then(() => {
|
||||
console.log('[Startup] Has PIN:', hasPin);
|
||||
|
||||
if (token) {
|
||||
// Token existe, ouvrir la fenêtre principale
|
||||
createMainWindow();
|
||||
} else if (offlineMode && hasPin && lastUserId) {
|
||||
// Mode offline activé avec PIN, ouvrir login offline
|
||||
console.log('[Startup] Opening offline login page');
|
||||
loginWindow = new BrowserWindow({
|
||||
width: 500,
|
||||
height: 900,
|
||||
resizable: false,
|
||||
...(process.platform !== 'darwin' && { icon: iconPath }),
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
frame: true,
|
||||
show: false,
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
const devPort = process.env.PORT || '4000';
|
||||
loginWindow.loadURL(`http://localhost:${devPort}/login/offline`);
|
||||
loginWindow.webContents.openDevTools();
|
||||
} else {
|
||||
loginWindow.loadURL('app://./login/offline/index.html');
|
||||
}
|
||||
|
||||
loginWindow.once('ready-to-show', () => {
|
||||
loginWindow?.show();
|
||||
});
|
||||
|
||||
loginWindow.on('closed', () => {
|
||||
loginWindow = null;
|
||||
});
|
||||
} else {
|
||||
// Pas de token ou pas de mode offline, ouvrir la fenêtre de login normale
|
||||
createLoginWindow();
|
||||
}
|
||||
|
||||
|
||||
244
electron/storage/SecureStorage.ts
Normal file
244
electron/storage/SecureStorage.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { safeStorage, app } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* SecureStorage - Replacement for electron-store using Electron's safeStorage API
|
||||
*
|
||||
* Uses OS-level encryption:
|
||||
* - macOS: Keychain
|
||||
* - Windows: DPAPI (Data Protection API)
|
||||
* - Linux: gnome-libsecret/kwallet
|
||||
*
|
||||
* Security notes:
|
||||
* - Protects against physical theft (when PC is off)
|
||||
* - Protects against other users on same machine
|
||||
* - Does NOT protect against malware running under same user
|
||||
* - On Linux, check getStorageBackend() - if 'basic_text', encryption is weak
|
||||
*/
|
||||
class SecureStorage {
|
||||
private storePath: string;
|
||||
private cache: Map<string, string> = new Map();
|
||||
private isLoaded: boolean = false;
|
||||
private appReady: boolean = false;
|
||||
|
||||
constructor() {
|
||||
const userDataPath = app.getPath('userData');
|
||||
this.storePath = path.join(userDataPath, 'secure-config.json');
|
||||
|
||||
// Wait for app to be ready before using safeStorage
|
||||
if (app.isReady()) {
|
||||
this.appReady = true;
|
||||
} else {
|
||||
app.whenReady().then(() => {
|
||||
this.appReady = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure data is loaded from disk (lazy loading)
|
||||
*/
|
||||
private ensureLoaded(): void {
|
||||
if (!this.isLoaded) {
|
||||
this.loadFromDisk();
|
||||
this.isLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load encrypted data from disk into memory cache
|
||||
*/
|
||||
private loadFromDisk(): void {
|
||||
try {
|
||||
if (!fs.existsSync(this.storePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileData = fs.readFileSync(this.storePath, 'utf-8');
|
||||
const parsed = JSON.parse(fileData);
|
||||
|
||||
// Load all values and store in cache
|
||||
for (const [key, storedValue] of Object.entries(parsed)) {
|
||||
if (typeof storedValue !== 'string' || storedValue.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (storedValue.startsWith('encrypted:')) {
|
||||
// Decrypt encrypted value
|
||||
const encryptedBase64 = storedValue.substring('encrypted:'.length);
|
||||
const buffer = Buffer.from(encryptedBase64, 'base64');
|
||||
const decrypted = safeStorage.decryptString(buffer);
|
||||
this.cache.set(key, decrypted);
|
||||
} else if (storedValue.startsWith('plain:')) {
|
||||
// Load plain value
|
||||
const plainValue = storedValue.substring('plain:'.length);
|
||||
this.cache.set(key, plainValue);
|
||||
} else {
|
||||
// Legacy format (try to decrypt)
|
||||
try {
|
||||
const buffer = Buffer.from(storedValue, 'base64');
|
||||
const decrypted = safeStorage.decryptString(buffer);
|
||||
this.cache.set(key, decrypted);
|
||||
} catch {
|
||||
// If decrypt fails, assume it's plain text
|
||||
this.cache.set(key, storedValue);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SecureStorage] Failed to load key '${key}':`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] Failed to load from disk:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save encrypted data from memory cache to disk
|
||||
*/
|
||||
private saveToDisk(): void {
|
||||
try {
|
||||
const data: Record<string, string> = {};
|
||||
|
||||
// Check if encryption is available
|
||||
const canEncrypt = safeStorage.isEncryptionAvailable();
|
||||
|
||||
for (const [key, value] of this.cache.entries()) {
|
||||
if (canEncrypt && safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
if (value && typeof value === 'string') {
|
||||
const buffer = safeStorage.encryptString(value);
|
||||
if (buffer && buffer.length > 0) {
|
||||
data[key] = `encrypted:${buffer.toString('base64')}`;
|
||||
} else {
|
||||
throw new Error(`Failed to encrypt key '${key}'`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Invalid value for key '${key}'`);
|
||||
}
|
||||
} catch (encryptError) {
|
||||
console.error(`[SecureStorage] CRITICAL: Cannot encrypt key '${key}':`, encryptError);
|
||||
throw encryptError;
|
||||
}
|
||||
} else {
|
||||
throw new Error('Encryption not available - cannot save securely');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(this.storePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(this.storePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] Failed to save to disk:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from secure storage
|
||||
* @param key - Storage key
|
||||
* @param defaultValue - Default value if key doesn't exist
|
||||
* @returns Stored value or default
|
||||
*/
|
||||
get<T = string>(key: string, defaultValue: T | null = null): T | null {
|
||||
this.ensureLoaded();
|
||||
const value = this.cache.get(key);
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Try to parse as JSON for objects/arrays
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
// Return as-is if not JSON
|
||||
return value as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in secure storage (kept in memory only)
|
||||
* @param key - Storage key
|
||||
* @param value - Value to store
|
||||
*/
|
||||
set(key: string, value: unknown): void {
|
||||
this.ensureLoaded();
|
||||
// Convert to string (JSON if object/array)
|
||||
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
|
||||
this.cache.set(key, stringValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a value from secure storage (memory only)
|
||||
* @param key - Storage key
|
||||
*/
|
||||
delete(key: string): void {
|
||||
this.ensureLoaded();
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key exists in secure storage
|
||||
* @param key - Storage key
|
||||
* @returns True if key exists
|
||||
*/
|
||||
has(key: string): boolean {
|
||||
this.ensureLoaded();
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data from secure storage (memory only)
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually save to disk (encrypted with safeStorage)
|
||||
* Call this when you want to persist data
|
||||
*/
|
||||
save(): void {
|
||||
this.saveToDisk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption is available
|
||||
* @returns True if OS-level encryption is available
|
||||
*/
|
||||
isEncryptionAvailable(): boolean {
|
||||
return safeStorage.isEncryptionAvailable();
|
||||
}
|
||||
}
|
||||
|
||||
// Store singleton in global scope to avoid multiple instances with dynamic imports
|
||||
declare global {
|
||||
var __secureStorageInstance: SecureStorage | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SecureStorage singleton instance
|
||||
* @returns SecureStorage instance
|
||||
*/
|
||||
export function getSecureStorage(): SecureStorage {
|
||||
if (!global.__secureStorageInstance) {
|
||||
global.__secureStorageInstance = new SecureStorage();
|
||||
|
||||
// Log encryption availability
|
||||
if (!global.__secureStorageInstance.isEncryptionAvailable()) {
|
||||
console.warn(
|
||||
'[SecureStorage] WARNING: OS-level encryption is not available. ' +
|
||||
'Data will still be stored but with reduced security.'
|
||||
);
|
||||
}
|
||||
}
|
||||
return global.__secureStorageInstance;
|
||||
}
|
||||
|
||||
export default SecureStorage;
|
||||
Reference in New Issue
Block a user