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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ function getSafeStorage() {
|
|||||||
return _safeStorageCache;
|
return _safeStorageCache;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
_safeStorageCache = null;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user