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
+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', () => {
const failure = new Error('decryption failed');
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' }, () => {
const projectRoot = path.join(__dirname, '..');
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8').replace(/\r\n?/gu, '\n');