From bf33ab3e9937d8b0ad23d8956730f2020f861942 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:07:21 +0200 Subject: [PATCH] fix: defer secure credential loading until ready Load encrypted account credentials only after Electron reaches its ready state so Windows DPAPI is available during startup. Retry transient secure-storage discovery instead of caching an unavailable result for the process lifetime. Add hidden two-process DPAPI regression coverage for the real renderer IPC path and bump the public version to 2.1.40. --- README.md | 2 +- lib/secret-store.js | 1 - main.js | 2 +- package-lock.json | 4 +- package.json | 2 +- tests/secret-store.test.js | 21 ++++ tests/startup-renderer.test.js | 175 +++++++++++++++++++++++++++++++++ 7 files changed, 201 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e28d482..c5f46f8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Multi-Hoster-Upload is a Windows desktop application for sending file batches to Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). -The latest public release is version 2.1.39. Use the release page for the executables and the full English changelog. +The latest public release is version 2.1.40. Use the release page for the executables and the full English changelog. ## Features diff --git a/lib/secret-store.js b/lib/secret-store.js index c11c124..8539336 100644 --- a/lib/secret-store.js +++ b/lib/secret-store.js @@ -30,7 +30,6 @@ function getSafeStorage() { return _safeStorageCache; } } catch {} - _safeStorageCache = null; return null; } diff --git a/main.js b/main.js index a917802..38c6480 100644 --- a/main.js +++ b/main.js @@ -128,7 +128,6 @@ let tray = null; let _cachedLogSettings = null; const configStore = new ConfigStore(app); configStore.setPerfLog((m) => { try { logInfo(m); } catch {} }); -_setLogSettingsSnapshot((configStore.load() || {}).globalSettings); const onlineBackupKeyring = createOnlineBackupKeyring({ filePath: path.join(app.getPath('userData'), 'online-backup-keys.json') }); @@ -1716,6 +1715,7 @@ app.whenReady().then(async () => { if (!_hasSingleInstanceLock) return; try { const _bootCfg = configStore.load(); + _setLogSettingsSnapshot(_bootCfg.globalSettings); setLogVerbose(!!(_bootCfg.globalSettings && _bootCfg.globalSettings.logVerbose)); } catch {} logMarker('APP START', { diff --git a/package-lock.json b/package-lock.json index f4ebb1c..4710ae6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.1.39", + "version": "2.1.40", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.1.39", + "version": "2.1.40", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index a539733..75120fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "2.1.39", + "version": "2.1.40", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/tests/secret-store.test.js b/tests/secret-store.test.js index eaab235..33c6f86 100644 --- a/tests/secret-store.test.js +++ b/tests/secret-store.test.js @@ -101,6 +101,27 @@ test('throws an identifiable error for encrypted values without secure storage', }); }); +test('retries secure storage discovery after transient unavailability', () => { + let available = false; + let checks = 0; + const encrypted = `enc:v1:${Buffer.from('protected:secret').toString('base64')}`; + withSecretStore(availableSafeStorage({ + isEncryptionAvailable: () => { + checks++; + return available; + } + }), secretStore => { + assert.throws( + () => secretStore.decryptField(encrypted), + error => error instanceof secretStore.SecretStoreError + && error.code === 'SECRET_STORE_UNAVAILABLE' + ); + available = true; + assert.equal(secretStore.decryptField(encrypted), 'secret'); + assert.equal(checks, 2); + }); +}); + test('throws an identifiable error when decryption fails', () => { const failure = new Error('decryption failed'); withSecretStore(availableSafeStorage({ decryptString: () => { throw failure; } }), secretStore => { diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 4cefc4a..665b254 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -3917,6 +3917,181 @@ app.whenReady().then(async () => { } }); +test('real main startup decrypts persisted credentials only after Electron is ready', { skip: process.platform !== 'win32' }, () => { + const projectRoot = path.join(__dirname, '..'); + const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-startup-dpapi-')); + try { + const appRoot = path.join(probeRoot, 'app'); + const userDataPath = path.join(probeRoot, 'user-data'); + const seedPath = path.join(appRoot, 'seed.cjs'); + const probePath = path.join(appRoot, 'probe.cjs'); + const outputPath = path.join(probeRoot, 'result.json'); + const secret = 'startup-dpapi-secret'; + fs.mkdirSync(appRoot, { recursive: true }); + for (const relativePath of ['main.js', 'preload.js', 'preload-drop-target.js', 'package.json']) { + fs.copyFileSync(path.join(projectRoot, relativePath), path.join(appRoot, relativePath)); + } + for (const relativePath of ['lib', 'renderer', 'assets']) { + fs.cpSync(path.join(projectRoot, relativePath), path.join(appRoot, relativePath), { recursive: true }); + } + fs.symlinkSync(path.join(projectRoot, 'node_modules'), path.join(appRoot, 'node_modules'), 'junction'); + fs.writeFileSync(seedPath, ` +const { app, safeStorage } = require('electron'); +const fs = require('node:fs'); +const path = require('node:path'); +const userDataPath = process.env.MHU_STARTUP_DPAPI_USER_DATA; +app.setPath('userData', userDataPath); +app.whenReady().then(() => { + if (!safeStorage.isEncryptionAvailable()) throw new Error('safeStorage unavailable'); + const encrypted = 'enc:v1:' + safeStorage.encryptString(process.env.MHU_STARTUP_DPAPI_SECRET).toString('base64'); + fs.mkdirSync(userDataPath, { recursive: true }); + fs.writeFileSync(path.join(userDataPath, 'electron-config.json'), JSON.stringify({ + hosters: { + 'voe.sx': [{ id: 'startup-dpapi', name: 'Startup DPAPI', enabled: true, authType: 'api', apiKey: encrypted }] + }, + globalSettings: { + language: 'de', + logVerbose: false, + logFilePath: path.join(userDataPath, 'fileuploader.log') + } + }), 'utf8'); + setTimeout(() => app.quit(), 500); +}).catch(error => { + process.stderr.write(error.stack || String(error)); + app.exit(1); +}); +`, 'utf8'); + fs.writeFileSync(probePath, ` +const { app, BrowserWindow, dialog, safeStorage } = require('electron'); +const fs = require('node:fs'); +const path = require('node:path'); +const outputPath = process.env.MHU_STARTUP_DPAPI_OUTPUT; +const userDataPath = process.env.MHU_STARTUP_DPAPI_USER_DATA; +app.setPath('userData', userDataPath); +BrowserWindow.prototype.show = function () {}; +BrowserWindow.prototype.showInactive = function () {}; +BrowserWindow.prototype.focus = function () {}; +app.focus = function () {}; +let errorDialogs = 0; +dialog.showErrorBox = function () { errorDialogs++; }; +let preReadySafeStorageCalls = 0; +let postReadyDecryptCalls = 0; +const originalIsEncryptionAvailable = safeStorage.isEncryptionAvailable.bind(safeStorage); +const originalDecryptString = safeStorage.decryptString.bind(safeStorage); +safeStorage.isEncryptionAvailable = function (...args) { + if (!app.isReady()) preReadySafeStorageCalls++; + return originalIsEncryptionAvailable(...args); +}; +safeStorage.decryptString = function (...args) { + if (app.isReady()) postReadyDecryptCalls++; + return originalDecryptString(...args); +}; +async function waitFor(read, timeoutMs = 20000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const value = await read(); + if (value) return value; + await new Promise(resolve => setTimeout(resolve, 25)); + } + throw new Error('startup DPAPI probe timed out'); +} +try { + require('./main.js'); +} catch (error) { + fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error) }), 'utf8'); + process.exit(1); +} +app.whenReady().then(async () => { + const window = await waitFor(() => BrowserWindow.getAllWindows().find(candidate => !candidate.isDestroyed()) || null); + const renderer = await waitFor(async () => { + try { + if (window.webContents.isLoading()) return null; + const url = window.webContents.getURL(); + if (!url.includes('/renderer/index.html')) return null; + const config = await window.webContents.executeJavaScript('window.api.getConfig()'); + const account = config.hosters['voe.sx'].find(candidate => candidate.id === 'startup-dpapi'); + return { url, decrypted: account.apiKey === process.env.MHU_STARTUP_DPAPI_SECRET }; + } catch { + return null; + } + }); + const liveWindows = BrowserWindow.getAllWindows().filter(candidate => !candidate.isDestroyed()); + fs.writeFileSync(outputPath, JSON.stringify({ + safeStorageAvailable: safeStorage.isEncryptionAvailable(), + preReadySafeStorageCalls, + postReadyDecryptCalls, + decrypted: renderer.decrypted, + rendererLoaded: renderer.url.includes('/renderer/index.html'), + liveWindows: liveWindows.length, + visibleWindows: liveWindows.filter(candidate => candidate.isVisible()).length, + errorDialogs + }), 'utf8'); + for (const candidate of liveWindows) { + if (!candidate.isDestroyed()) candidate.destroy(); + } + app.exit(0); +}).catch(error => { + fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error) }), 'utf8'); + app.exit(1); +}); +`, 'utf8'); + const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe'); + const environment = { + ...process.env, + MHU_PERF: '0', + MHU_STARTUP_DPAPI_OUTPUT: outputPath, + MHU_STARTUP_DPAPI_USER_DATA: userDataPath, + MHU_STARTUP_DPAPI_SECRET: secret + }; + delete environment.RUN_UI_SMOKE; + const seed = spawnSync(electronPath, [seedPath, `--user-data-dir=${userDataPath}`], { + cwd: appRoot, + env: environment, + encoding: 'utf8', + windowsHide: true, + timeout: 30000 + }); + assert.equal(seed.status, 0, `${seed.stdout}\n${seed.stderr}`); + const rawConfig = fs.readFileSync(path.join(userDataPath, 'electron-config.json'), 'utf8'); + assert.match(rawConfig, /"apiKey"\s*:\s*"enc:v1:/u); + assert.equal(rawConfig.includes(secret), false); + const execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], { + cwd: appRoot, + env: environment, + encoding: 'utf8', + windowsHide: true, + timeout: 30000 + }); + const probeOutput = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : ''; + assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}\n${probeOutput}`); + const result = JSON.parse(probeOutput); + assert.deepEqual({ + safeStorageAvailable: result.safeStorageAvailable, + preReadySafeStorageCalls: result.preReadySafeStorageCalls, + decrypted: result.decrypted, + rendererLoaded: result.rendererLoaded, + liveWindows: result.liveWindows, + visibleWindows: result.visibleWindows, + errorDialogs: result.errorDialogs + }, { + safeStorageAvailable: true, + preReadySafeStorageCalls: 0, + decrypted: true, + rendererLoaded: true, + liveWindows: 1, + visibleWindows: 0, + errorDialogs: 0 + }); + assert.ok(result.postReadyDecryptCalls >= 1); + const allOutput = `${seed.stdout}\n${seed.stderr}\n${execution.stdout}\n${execution.stderr}\n${probeOutput}`; + assert.doesNotMatch(allOutput, /SECRET_STORE_(?:UNAVAILABLE|DECRYPT_FAILED)/u); + assert.doesNotMatch(allOutput, new RegExp(secret, 'u')); + } finally { + fs.rmSync(probeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + assert.equal(fs.existsSync(probeRoot), false); + } +}); + test('persisted automation pause survives runtime restart and resumes one reconciliation without starting previews', { skip: process.platform !== 'win32' }, () => { const projectRoot = path.join(__dirname, '..'); const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8').replace(/\r\n?/gu, '\n');