fix: defer secure credential loading until ready
CI / verify (push) Canceled after 0s

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.
This commit is contained in:
Sucukdeluxe
2026-08-27 16:07:21 +02:00
parent d9c66fe20f
commit bf33ab3e99
7 changed files with 201 additions and 6 deletions
+1 -1
View File
@@ -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). 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 ## Features
-1
View File
@@ -30,7 +30,6 @@ function getSafeStorage() {
return _safeStorageCache; return _safeStorageCache;
} }
} catch {} } catch {}
_safeStorageCache = null;
return null; return null;
} }
+1 -1
View File
@@ -128,7 +128,6 @@ let tray = null;
let _cachedLogSettings = null; let _cachedLogSettings = null;
const configStore = new ConfigStore(app); const configStore = new ConfigStore(app);
configStore.setPerfLog((m) => { try { logInfo(m); } catch {} }); configStore.setPerfLog((m) => { try { logInfo(m); } catch {} });
_setLogSettingsSnapshot((configStore.load() || {}).globalSettings);
const onlineBackupKeyring = createOnlineBackupKeyring({ const onlineBackupKeyring = createOnlineBackupKeyring({
filePath: path.join(app.getPath('userData'), 'online-backup-keys.json') filePath: path.join(app.getPath('userData'), 'online-backup-keys.json')
}); });
@@ -1716,6 +1715,7 @@ app.whenReady().then(async () => {
if (!_hasSingleInstanceLock) return; if (!_hasSingleInstanceLock) return;
try { try {
const _bootCfg = configStore.load(); const _bootCfg = configStore.load();
_setLogSettingsSnapshot(_bootCfg.globalSettings);
setLogVerbose(!!(_bootCfg.globalSettings && _bootCfg.globalSettings.logVerbose)); setLogVerbose(!!(_bootCfg.globalSettings && _bootCfg.globalSettings.logVerbose));
} catch {} } catch {}
logMarker('APP START', { logMarker('APP START', {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.39", "version": "2.1.40",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.39", "version": "2.1.40",
"dependencies": { "dependencies": {
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"undici": "^7.29.0", "undici": "^7.29.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.39", "version": "2.1.40",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+21
View File
@@ -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', () => { test('throws an identifiable error when decryption fails', () => {
const failure = new Error('decryption failed'); const failure = new Error('decryption failed');
withSecretStore(availableSafeStorage({ decryptString: () => { throw failure; } }), secretStore => { withSecretStore(availableSafeStorage({ decryptString: () => { throw failure; } }), secretStore => {
+175
View File
@@ -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' }, () => { test('persisted automation pause survives runtime restart and resumes one reconciliation without starting previews', { skip: process.platform !== 'win32' }, () => {
const projectRoot = path.join(__dirname, '..'); const projectRoot = path.join(__dirname, '..');
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8').replace(/\r\n?/gu, '\n'); const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8').replace(/\r\n?/gu, '\n');