fix(security): remove plaintext credential storage fallback

Credential writes now require Electron safeStorage encryption. Legacy plaintext values remain readable for migration and are encrypted during the next successful save. The UI override and status IPC are removed.
This commit is contained in:
Sucukdeluxe
2026-08-12 00:25:19 +02:00
parent 1ee888c762
commit d3bac698c1
14 changed files with 90 additions and 87 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ English is the default language for new profiles. Open **Settings > General** to
### Local data and credentials ### Local data and credentials
Settings, pending queue state, and upload history are stored in Electron's user-data directory. Passwords and API keys are encrypted with Electron `safeStorage` before they are written when operating-system encryption is available and encryption succeeds. On Windows, `safeStorage` uses DPAPI and ties encrypted values to the current Windows user profile. If operating-system encryption is unavailable or encryption fails, passwords and API keys are stored as plaintext on disk. Settings, pending queue state, and upload history are stored in Electron's user-data directory. Passwords and API keys must be encrypted with Electron `safeStorage` before they are written. On Windows, `safeStorage` uses DPAPI and ties encrypted values to the current Windows user profile. If operating-system encryption is unavailable or encryption fails, the write is rejected and credentials are never written as plaintext. Legacy plaintext credentials remain readable for migration and are encrypted during the next successful save.
Credentials are decrypted when required for account validation or upload to the selected host. Do not share application data files, backup files, screenshots containing credentials, or generated backup keys. Credentials are decrypted when required for account validation or upload to the selected host. Do not share application data files, backup files, screenshots containing credentials, or generated backup keys.
+6 -7
View File
@@ -73,7 +73,6 @@ const DEFAULTS = {
// load() sets logMode after the merge, looking at the saved-only data. // load() sets logMode after the merge, looking at the saved-only data.
resumeQueueOnLaunch: true, resumeQueueOnLaunch: true,
autoStartRestoredQueue: false, autoStartRestoredQueue: false,
allowPlaintextCredentialStorage: false,
parallelUploadCount: 0, // 0 = use per-hoster limits only parallelUploadCount: 0, // 0 = use per-hoster limits only
scaleParallelUploads: false, scaleParallelUploads: false,
lastBrowseDirectory: '', lastBrowseDirectory: '',
@@ -182,7 +181,7 @@ function applyHistoryRetention(history, retention, nowMs) {
} }
class ConfigStore { class ConfigStore {
constructor(app, options = {}) { constructor(app) {
const useUserDataDir = app && ( const useUserDataDir = app && (
app.isPackaged || app.isPackaged ||
(app.commandLine && typeof app.commandLine.hasSwitch === 'function' && app.commandLine.hasSwitch('user-data-dir')) (app.commandLine && typeof app.commandLine.hasSwitch === 'function' && app.commandLine.hasSwitch('user-data-dir'))
@@ -201,7 +200,6 @@ class ConfigStore {
this._cacheKey = ''; this._cacheKey = '';
this._perfLog = null; this._perfLog = null;
this._wqDepth = 0; this._wqDepth = 0;
this._allowPlaintextCredentialStorage = options.allowPlaintextCredentialStorage === true;
// Migrate config from old location if current doesn't exist // Migrate config from old location if current doesn't exist
if (!fs.existsSync(this.filePath) && app && app.isPackaged) { if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
@@ -448,6 +446,7 @@ class ConfigStore {
...DEFAULTS.globalSettings, ...DEFAULTS.globalSettings,
...savedGlobal ...savedGlobal
}; };
delete globalSettings.allowPlaintextCredentialStorage;
// Deep-merge nested objects so new keys are always present // Deep-merge nested objects so new keys are always present
for (const key of Object.keys(DEFAULTS.globalSettings)) { for (const key of Object.keys(DEFAULTS.globalSettings)) {
const def = DEFAULTS.globalSettings[key]; const def = DEFAULTS.globalSettings[key];
@@ -487,10 +486,10 @@ class ConfigStore {
// on every write was a primary long-running main-thread stall. // on every write was a primary long-running main-thread stall.
_serializeForDisk(config) { _serializeForDisk(config) {
const hosters = this._clone(config.hosters || {}); const hosters = this._clone(config.hosters || {});
secretStore.encryptCredentials({ hosters }, { const globalSettings = this._clone(config.globalSettings || {});
allowPlaintext: this._allowPlaintextCredentialStorage || config.globalSettings?.allowPlaintextCredentialStorage === true delete globalSettings.allowPlaintextCredentialStorage;
}); secretStore.encryptCredentials({ hosters });
return JSON.stringify({ ...config, hosters }, null, 2); return JSON.stringify({ ...config, globalSettings, hosters }, null, 2);
} }
_commit(config) { _commit(config) {
+3 -10
View File
@@ -38,23 +38,17 @@ function isEncrypted(value) {
return typeof value === 'string' && value.startsWith(SENTINEL); return typeof value === 'string' && value.startsWith(SENTINEL);
} }
function getAvailabilityStatus() { function encryptField(value) {
return getSafeStorage() ? 'available' : 'unavailable';
}
function encryptField(value, options = {}) {
if (!value || typeof value !== 'string') return value; if (!value || typeof value !== 'string') return value;
if (isEncrypted(value)) return value; if (isEncrypted(value)) return value;
const ss = getSafeStorage(); const ss = getSafeStorage();
if (!ss) { if (!ss) {
if (options.allowPlaintext === true) return value;
throw new SecretStoreError('SECRET_STORE_UNAVAILABLE', 'Sicherer Zugangsdaten-Speicher ist nicht verfügbar'); throw new SecretStoreError('SECRET_STORE_UNAVAILABLE', 'Sicherer Zugangsdaten-Speicher ist nicht verfügbar');
} }
try { try {
const buf = ss.encryptString(value); const buf = ss.encryptString(value);
return SENTINEL + buf.toString('base64'); return SENTINEL + buf.toString('base64');
} catch (cause) { } catch (cause) {
if (options.allowPlaintext === true) return value;
throw new SecretStoreError('SECRET_STORE_ENCRYPT_FAILED', 'Zugangsdaten konnten nicht sicher verschlüsselt werden', cause); throw new SecretStoreError('SECRET_STORE_ENCRYPT_FAILED', 'Zugangsdaten konnten nicht sicher verschlüsselt werden', cause);
} }
} }
@@ -88,14 +82,13 @@ function mapHosterAccounts(config, fn) {
return config; return config;
} }
function encryptCredentials(config, options = {}) { function encryptCredentials(config) {
return mapHosterAccounts(config, value => encryptField(value, options)); return mapHosterAccounts(config, encryptField);
} }
function decryptCredentials(config) { return mapHosterAccounts(config, decryptField); } function decryptCredentials(config) { return mapHosterAccounts(config, decryptField); }
module.exports = { module.exports = {
SecretStoreError, SecretStoreError,
getAvailabilityStatus,
encryptField, encryptField,
decryptField, decryptField,
encryptCredentials, encryptCredentials,
-3
View File
@@ -7,7 +7,6 @@ nativeTheme.themeSource = 'dark';
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const ConfigStore = require('./lib/config-store'); const ConfigStore = require('./lib/config-store');
const secretStore = require('./lib/secret-store');
const UploadManager = require('./lib/upload-manager'); const UploadManager = require('./lib/upload-manager');
const { createSourceFileCleanup } = require('./lib/source-file-cleanup'); const { createSourceFileCleanup } = require('./lib/source-file-cleanup');
const SourceDeleteJournal = require('./lib/source-delete-journal'); const SourceDeleteJournal = require('./lib/source-delete-journal');
@@ -1699,8 +1698,6 @@ ipcMain.handle('get-config', () => {
return configStore.load(); return configStore.load();
}); });
ipcMain.handle('secret-store:status', () => ({ status: secretStore.getAvailabilityStatus() }));
ipcMain.handle('save-config', async (_event, config) => { ipcMain.handle('save-config', async (_event, config) => {
assertConfigWriteAllowed(); assertConfigWriteAllowed();
await configStore.save(config); await configStore.save(config);
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.12", "version": "2.1.13",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.12", "version": "2.1.13",
"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.12", "version": "2.1.13",
"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": {
-1
View File
@@ -4,7 +4,6 @@ contextBridge.exposeInMainWorld('api', {
// Config // Config
getConfig: () => ipcRenderer.invoke('get-config'), getConfig: () => ipcRenderer.invoke('get-config'),
saveConfig: (config) => ipcRenderer.invoke('save-config', config), saveConfig: (config) => ipcRenderer.invoke('save-config', config),
getSecretStoreStatus: () => ipcRenderer.invoke('secret-store:status'),
getHistory: () => ipcRenderer.invoke('get-history'), getHistory: () => ipcRenderer.invoke('get-history'),
clearHistory: () => ipcRenderer.invoke('clear-history'), clearHistory: () => ipcRenderer.invoke('clear-history'),
pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }), pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }),
-25
View File
@@ -4255,14 +4255,6 @@ function renderSettings() {
<input type="checkbox" class="settings-autosave" id="showDropTargetInput" ${globalSettings.showDropTarget ? 'checked' : ''}> <input type="checkbox" class="settings-autosave" id="showDropTargetInput" ${globalSettings.showDropTarget ? 'checked' : ''}>
</div> </div>
</div> </div>
<div class="settings-option credential-fallback-option">
<div class="settings-option-copy">
<label for="allowPlaintextCredentialStorageInput">Unsichere Klartext-Speicherung erlauben</label>
<span class="settings-option-description">Nur verwenden, wenn der sichere Betriebssystem-Speicher dauerhaft nicht verfügbar ist. Passwörter und API-Keys liegen dann lesbar in der Konfigurationsdatei.</span>
<span class="panel-status" id="secretStoreStatus">Prüfe</span>
</div>
<input type="checkbox" class="settings-autosave" id="allowPlaintextCredentialStorageInput" ${globalSettings.allowPlaintextCredentialStorage ? 'checked' : ''}>
</div>
<div class="settings-section-label">Programmupdate</div> <div class="settings-section-label">Programmupdate</div>
<div class="settings-row program-update-row program-update-card"> <div class="settings-row program-update-row program-update-card">
<div class="program-update-copy"> <div class="program-update-copy">
@@ -4870,13 +4862,6 @@ function renderSettings() {
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath); document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder()); document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder());
document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', requestUpdateCheck); document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', requestUpdateCheck);
window.api.getSecretStoreStatus().then(result => {
const badge = document.getElementById('secretStoreStatus');
if (!badge) return;
const available = result?.status === 'available';
badge.textContent = available ? 'Sicher verfügbar' : 'Nicht verfügbar';
badge.classList.toggle('active', available);
}).catch(() => {});
_syncHeaderUpdateState(); _syncHeaderUpdateState();
container.querySelectorAll('.settings-autosave').forEach((input) => { container.querySelectorAll('.settings-autosave').forEach((input) => {
const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input'; const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input';
@@ -4894,15 +4879,6 @@ function renderSettings() {
}); });
if (!confirmed) input.checked = false; if (!confirmed) input.checked = false;
} }
if (input.id === 'allowPlaintextCredentialStorageInput' && input.checked) {
const confirmed = await showAppConfirm({
title: 'Zugangsdaten unverschlüsselt speichern?',
message: 'Passwörter und API-Keys werden dann im Klartext auf diesem PC gespeichert. Andere Benutzer oder Programme mit Dateizugriff können sie lesen.',
confirmText: 'Klartext-Speicherung erlauben',
danger: true
});
if (!confirmed) input.checked = false;
}
markSettingsDirty(); markSettingsDirty();
}); });
}); });
@@ -5028,7 +5004,6 @@ async function performSaveSettings(options = {}) {
})(), })(),
resumeQueueOnLaunch: elChk('resumeQueueOnLaunchInput', cur.resumeQueueOnLaunch !== false), resumeQueueOnLaunch: elChk('resumeQueueOnLaunchInput', cur.resumeQueueOnLaunch !== false),
autoStartRestoredQueue: elChk('autoStartRestoredQueueInput', !!cur.autoStartRestoredQueue), autoStartRestoredQueue: elChk('autoStartRestoredQueueInput', !!cur.autoStartRestoredQueue),
allowPlaintextCredentialStorage: elChk('allowPlaintextCredentialStorageInput', !!cur.allowPlaintextCredentialStorage),
parallelUploadCount: elInt('parallelUploadCountInput', cur.parallelUploadCount ?? 0, 0, 0, 100), parallelUploadCount: elInt('parallelUploadCountInput', cur.parallelUploadCount ?? 0, 0, 0, 100),
scaleParallelUploads: elChk('scaleParallelUploadsInput', !!cur.scaleParallelUploads), scaleParallelUploads: elChk('scaleParallelUploadsInput', !!cur.scaleParallelUploads),
removeFromQueueOnDone: elChk('removeFromQueueOnDoneInput', !!cur.removeFromQueueOnDone), removeFromQueueOnDone: elChk('removeFromQueueOnDoneInput', !!cur.removeFromQueueOnDone),
-7
View File
@@ -474,13 +474,6 @@
['Zugangsdaten konnten nicht sicher verschlüsselt werden', 'Credentials could not be encrypted securely'], ['Zugangsdaten konnten nicht sicher verschlüsselt werden', 'Credentials could not be encrypted securely'],
['Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden', 'Stored credentials could not be decrypted'], ['Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden', 'Stored credentials could not be decrypted'],
['Zugangsdaten gesperrt', 'Credentials locked'], ['Zugangsdaten gesperrt', 'Credentials locked'],
['Unsichere Klartext-Speicherung erlauben', 'Allow insecure plaintext storage'],
['Nur verwenden, wenn der sichere Betriebssystem-Speicher dauerhaft nicht verfügbar ist. Passwörter und API-Keys liegen dann lesbar in der Konfigurationsdatei.', 'Use only when secure operating system storage is permanently unavailable. Passwords and API keys will then be readable in the configuration file.'],
['Sicher verfügbar', 'Securely available'],
['Nicht verfügbar', 'Unavailable'],
['Zugangsdaten unverschlüsselt speichern?', 'Store credentials without encryption?'],
['Passwörter und API-Keys werden dann im Klartext auf diesem PC gespeichert. Andere Benutzer oder Programme mit Dateizugriff können sie lesen.', 'Passwords and API keys will then be stored as plaintext on this PC. Other users or programs with file access can read them.'],
['Klartext-Speicherung erlauben', 'Allow plaintext storage'],
['Die Verlaufsdatei ist beschädigt und wurde nicht verändert', 'The history file is damaged and was not changed'], ['Die Verlaufsdatei ist beschädigt und wurde nicht verändert', 'The history file is damaged and was not changed'],
['Verlauf und Aufbewahrung konnten nicht konsistent gespeichert werden', 'History and retention could not be saved consistently'], ['Verlauf und Aufbewahrung konnten nicht konsistent gespeichert werden', 'History and retention could not be saved consistently'],
['Kein Ordnerpfad angegeben', 'No folder path was provided'], ['Kein Ordnerpfad angegeben', 'No folder path was provided'],
-10
View File
@@ -3318,16 +3318,6 @@ input[type="checkbox"] {
border-color: var(--danger); border-color: var(--danger);
} }
.settings-option.credential-fallback-option {
margin-top: 12px;
border-color: color-mix(in srgb, var(--danger) 72%, var(--border));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger) 10%, transparent);
}
.settings-option.credential-fallback-option:focus-within {
border-color: var(--danger);
}
.settings-option-description, .settings-option-description,
.hint { .hint {
color: var(--text-dim); color: var(--text-dim);
+39 -3
View File
@@ -3,7 +3,20 @@ const assert = require('node:assert/strict');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const Module = require('node:module');
const originalLoad = Module._load;
const safeStorage = {
isEncryptionAvailable: () => true,
encryptString: value => Buffer.from(`test-protected:${value}`),
decryptString: value => value.toString().replace(/^test-protected:/, '')
};
Module._load = function load(request, parent, isMain) {
if (request === 'electron') return { safeStorage };
return originalLoad.call(this, request, parent, isMain);
};
const ConfigStore = require('../lib/config-store'); const ConfigStore = require('../lib/config-store');
require('../lib/secret-store').encryptField('test-initialization');
Module._load = originalLoad;
let tmpDir; let tmpDir;
let store; let store;
@@ -15,7 +28,7 @@ function createStore() {
}; };
// ConfigStore uses path.join(__dirname, '..') for non-packaged // ConfigStore uses path.join(__dirname, '..') for non-packaged
// We override by setting filePath directly // We override by setting filePath directly
store = new ConfigStore(fakeApp, { allowPlaintextCredentialStorage: true }); store = new ConfigStore(fakeApp);
store.filePath = path.join(tmpDir, 'electron-config.json'); store.filePath = path.join(tmpDir, 'electron-config.json');
store.historyPath = path.join(tmpDir, 'electron-history.json'); store.historyPath = path.join(tmpDir, 'electron-history.json');
return store; return store;
@@ -46,7 +59,7 @@ describe('ConfigStore', () => {
if (name === 'exe') return path.join(isolatedDir, 'Multi-Hoster-Upload.exe'); if (name === 'exe') return path.join(isolatedDir, 'Multi-Hoster-Upload.exe');
throw new Error(`Unexpected app path: ${name}`); throw new Error(`Unexpected app path: ${name}`);
} }
}, { allowPlaintextCredentialStorage: true }); });
try { try {
assert.equal(explicitStore.filePath, path.join(isolatedDir, 'electron-config.json')); assert.equal(explicitStore.filePath, path.join(isolatedDir, 'electron-config.json'));
@@ -76,6 +89,7 @@ describe('ConfigStore', () => {
assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing'); assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing');
assert.equal(config.globalSettings.logFilePath, ''); assert.equal(config.globalSettings.logFilePath, '');
assert.equal(config.globalSettings.resumeQueueOnLaunch, true); assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
assert.equal(Object.hasOwn(config.globalSettings, 'allowPlaintextCredentialStorage'), false);
assert.equal(config.globalSettings.parallelUploadCount, 0); assert.equal(config.globalSettings.parallelUploadCount, 0);
assert.equal(config.globalSettings.scaleParallelUploads, false); assert.equal(config.globalSettings.scaleParallelUploads, false);
assert.equal(config.globalSettings.lastBrowseDirectory, ''); assert.equal(config.globalSettings.lastBrowseDirectory, '');
@@ -83,6 +97,28 @@ describe('ConfigStore', () => {
assert.deepEqual(config.history, []); assert.deepEqual(config.history, []);
}); });
it('drops the retired plaintext credential setting from legacy configurations', () => {
fs.writeFileSync(store.filePath, JSON.stringify({
hosters: {},
hosterSettings: {},
globalSettings: { allowPlaintextCredentialStorage: true }
}), 'utf-8');
const config = store.load();
assert.equal(Object.hasOwn(config.globalSettings, 'allowPlaintextCredentialStorage'), false);
});
it('never writes the retired plaintext credential setting back to disk', async () => {
await store.save({
globalSettings: { allowPlaintextCredentialStorage: true }
});
const saved = JSON.parse(fs.readFileSync(store.filePath, 'utf-8'));
assert.equal(Object.hasOwn(saved.globalSettings, 'allowPlaintextCredentialStorage'), false);
});
it('keeps permanent source deletion disabled by default', () => { it('keeps permanent source deletion disabled by default', () => {
const config = store.load(); const config = store.load();
assert.equal(config.globalSettings.deleteSourceAfterSuccessfulUpload, false); assert.equal(config.globalSettings.deleteSourceAfterSuccessfulUpload, false);
@@ -579,7 +615,7 @@ describe('ConfigStore history split (electron-history.json)', () => {
let s; let s;
function makeStore() { function makeStore() {
const st = new ConfigStore({ isPackaged: false, getPath: () => dir }, { allowPlaintextCredentialStorage: true }); const st = new ConfigStore({ isPackaged: false, getPath: () => dir });
st.filePath = path.join(dir, 'electron-config.json'); st.filePath = path.join(dir, 'electron-config.json');
st.historyPath = path.join(dir, 'electron-history.json'); st.historyPath = path.join(dir, 'electron-history.json');
return st; return st;
+20 -2
View File
@@ -3,6 +3,25 @@ const assert = require('node:assert');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const Module = require('node:module');
const originalLoad = Module._load;
Module._load = function load(request, parent, isMain) {
if (request === 'electron') {
return {
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: value => Buffer.from(`test-protected:${value}`),
decryptString: value => value.toString().replace(/^test-protected:/, '')
}
};
}
return originalLoad.call(this, request, parent, isMain);
};
const ConfigStore = require('../lib/config-store');
require('../lib/secret-store').encryptField('test-initialization');
Module._load = originalLoad;
// Minimal app mock for ConfigStore // Minimal app mock for ConfigStore
function createTestConfigStore() { function createTestConfigStore() {
@@ -12,8 +31,7 @@ function createTestConfigStore() {
getPath: (name) => tmpDir, getPath: (name) => tmpDir,
getPath: () => tmpDir getPath: () => tmpDir
}; };
const ConfigStore = require('../lib/config-store'); const store = new ConfigStore(mockApp);
const store = new ConfigStore(mockApp, { allowPlaintextCredentialStorage: true });
store.filePath = path.join(tmpDir, 'test-config.json'); store.filePath = path.join(tmpDir, 'test-config.json');
return { store, tmpDir }; return { store, tmpDir };
} }
+16 -15
View File
@@ -28,15 +28,6 @@ function availableSafeStorage(overrides = {}) {
}; };
} }
test('reports whether secure credential storage is available', () => {
withSecretStore(availableSafeStorage(), secretStore => {
assert.equal(secretStore.getAvailabilityStatus(), 'available');
});
withSecretStore(null, secretStore => {
assert.equal(secretStore.getAvailabilityStatus(), 'unavailable');
});
});
test('encrypts and decrypts fields when secure storage is available', () => { test('encrypts and decrypts fields when secure storage is available', () => {
withSecretStore(availableSafeStorage(), secretStore => { withSecretStore(availableSafeStorage(), secretStore => {
const encrypted = secretStore.encryptField('secret'); const encrypted = secretStore.encryptField('secret');
@@ -55,13 +46,18 @@ test('refuses plaintext storage by default when secure storage is unavailable',
}); });
}); });
test('allows plaintext storage only through an explicit opt-in', () => { test('never allows plaintext storage when secure storage is unavailable', () => {
withSecretStore(null, secretStore => { withSecretStore(null, secretStore => {
assert.equal(secretStore.encryptField('secret', { allowPlaintext: true }), 'secret'); assert.throws(
() => secretStore.encryptField('secret', { allowPlaintext: true }),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_UNAVAILABLE'
);
const config = { hosters: { example: [{ password: 'secret' }] } }; const config = { hosters: { example: [{ password: 'secret' }] } };
assert.equal( assert.throws(
secretStore.encryptCredentials(config, { allowPlaintext: true }).hosters.example[0].password, () => secretStore.encryptCredentials(config, { allowPlaintext: true }),
'secret' error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_UNAVAILABLE'
); );
}); });
}); });
@@ -75,7 +71,12 @@ test('refuses plaintext storage by default when encryption fails', () => {
&& error.code === 'SECRET_STORE_ENCRYPT_FAILED' && error.code === 'SECRET_STORE_ENCRYPT_FAILED'
&& error.cause === failure && error.cause === failure
); );
assert.equal(secretStore.encryptField('secret', { allowPlaintext: true }), 'secret'); assert.throws(
() => secretStore.encryptField('secret', { allowPlaintext: true }),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_ENCRYPT_FAILED'
&& error.cause === failure
);
}); });
}); });
+2
View File
@@ -1031,6 +1031,8 @@ setTimeout(async () => {
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'uploads\\']")?.click()'); await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'uploads\\']")?.click()');
const uploadSettingsState = await wc.executeJavaScript('(() => { const activePage = document.querySelector(".settings-subpage.active"); return [activePage?.dataset.subpage, activePage?.querySelector("h3")?.textContent.trim(), document.querySelector("label[for=removeFromQueueOnDoneInput]")?.textContent.trim(), document.getElementById("removeFromQueueOnDoneInput")?.closest(".settings-option")?.querySelector(".settings-option-description")?.textContent.trim()].join("|"); })()'); const uploadSettingsState = await wc.executeJavaScript('(() => { const activePage = document.querySelector(".settings-subpage.active"); return [activePage?.dataset.subpage, activePage?.querySelector("h3")?.textContent.trim(), document.querySelector("label[for=removeFromQueueOnDoneInput]")?.textContent.trim(), document.getElementById("removeFromQueueOnDoneInput")?.closest(".settings-option")?.querySelector(".settings-option-description")?.textContent.trim()].join("|"); })()');
check('Upload completion behavior is immediately findable', uploadSettingsState === 'uploads|Upload-Verhalten|Nach Abschluss aus der Liste entfernen|Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.'); check('Upload completion behavior is immediately findable', uploadSettingsState === 'uploads|Upload-Verhalten|Nach Abschluss aus der Liste entfernen|Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.');
const plaintextCredentialOverride = await wc.executeJavaScript('(() => ({ control: document.getElementById("allowPlaintextCredentialStorageInput"), copy: document.body.textContent.includes("Unsichere Klartext-Speicherung"), bridge: typeof window.api.getSecretStoreStatus }))()');
check('Settings expose no plaintext credential storage override', plaintextCredentialOverride.control === null && plaintextCredentialOverride.copy === false && plaintextCredentialOverride.bridge === 'undefined');
const settingsTypography = await wc.executeJavaScript('(() => { const size = selector => parseFloat(getComputedStyle(document.querySelector(selector)).fontSize); return { heading: size(".settings-subpage.active .settings-page-header h3"), intro: size(".settings-subpage.active .settings-page-header p"), section: size(".settings-subpage.active .settings-section-label"), rowLabel: size(".settings-subpage.active .settings-row > label"), hint: size(".settings-subpage.active .hint"), optionLabel: size(".settings-subpage.active .settings-option-copy label"), optionDescription: size(".settings-subpage.active .settings-option-description"), navigation: size(".settings-nav-button"), search: size("#settingsSearchInput") }; })()'); const settingsTypography = await wc.executeJavaScript('(() => { const size = selector => parseFloat(getComputedStyle(document.querySelector(selector)).fontSize); return { heading: size(".settings-subpage.active .settings-page-header h3"), intro: size(".settings-subpage.active .settings-page-header p"), section: size(".settings-subpage.active .settings-section-label"), rowLabel: size(".settings-subpage.active .settings-row > label"), hint: size(".settings-subpage.active .hint"), optionLabel: size(".settings-subpage.active .settings-option-copy label"), optionDescription: size(".settings-subpage.active .settings-option-description"), navigation: size(".settings-nav-button"), search: size("#settingsSearchInput") }; })()');
check('Settings use the enlarged readable typography scale', settingsTypography.heading >= 22 && settingsTypography.intro >= 14 && settingsTypography.section >= 12 && settingsTypography.rowLabel >= 14 && settingsTypography.hint >= 12 && settingsTypography.optionLabel >= 14 && settingsTypography.optionDescription >= 12 && settingsTypography.navigation >= 13 && settingsTypography.search >= 13); check('Settings use the enlarged readable typography scale', settingsTypography.heading >= 22 && settingsTypography.intro >= 14 && settingsTypography.section >= 12 && settingsTypography.rowLabel >= 14 && settingsTypography.hint >= 12 && settingsTypography.optionLabel >= 14 && settingsTypography.optionDescription >= 12 && settingsTypography.navigation >= 13 && settingsTypography.search >= 13);
const settingsSelection = await wc.executeJavaScript('(() => ({ heading: getComputedStyle(document.querySelector(".settings-subpage.active .settings-page-header h3")).userSelect, hint: getComputedStyle(document.querySelector(".settings-subpage.active .hint")).userSelect, input: getComputedStyle(document.getElementById("globalMaxSpeedMbsInput")).userSelect }))()'); const settingsSelection = await wc.executeJavaScript('(() => ({ heading: getComputedStyle(document.querySelector(".settings-subpage.active .settings-page-header h3")).userSelect, hint: getComputedStyle(document.querySelector(".settings-subpage.active .hint")).userSelect, input: getComputedStyle(document.getElementById("globalMaxSpeedMbsInput")).userSelect }))()');