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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user