feat(security): encrypt persisted provider credentials
Protect every remembered provider credential with Electron safeStorage and restore it only inside the main process. Migrate legacy plaintext settings atomically across the primary config and its backup while keeping credentials memory-only when encryption is unavailable or remembering is disabled. Initialize credential protection after app readiness but before settings are loaded, add masked renderer projection metadata, and cover encryption, migration, unavailable-storage, and persistence behavior with focused tests.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import {
|
||||
configureCredentialProtector,
|
||||
CredentialProtector,
|
||||
protectPersistedSettings,
|
||||
projectSettingsForRenderer,
|
||||
restorePersistedSettings
|
||||
} from "../src/main/credential-protection";
|
||||
|
||||
function createProtector(available = true): CredentialProtector {
|
||||
return {
|
||||
isEncryptionAvailable: () => available,
|
||||
encryptString: (value) => Buffer.from(value, "utf8").reverse(),
|
||||
decryptString: (value) => Buffer.from(value).reverse().toString("utf8")
|
||||
};
|
||||
}
|
||||
|
||||
describe("credential protection", () => {
|
||||
beforeEach(() => {
|
||||
configureCredentialProtector(createProtector());
|
||||
});
|
||||
|
||||
it("protects remembered provider values and restores them for the main process", () => {
|
||||
const input = {
|
||||
...defaultSettings(),
|
||||
rememberToken: true,
|
||||
token: "value-to-protect",
|
||||
megaLogin: "account@example.invalid",
|
||||
megaPassword: "password-value"
|
||||
};
|
||||
|
||||
const persisted = protectPersistedSettings(input);
|
||||
|
||||
expect(persisted.token).not.toBe(input.token);
|
||||
expect(persisted.megaLogin).not.toBe(input.megaLogin);
|
||||
expect(JSON.stringify(persisted)).not.toContain(input.token);
|
||||
expect(restorePersistedSettings(persisted)).toMatchObject({
|
||||
token: input.token,
|
||||
megaLogin: input.megaLogin,
|
||||
megaPassword: input.megaPassword
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts plaintext values once so existing settings can be migrated", () => {
|
||||
const input = {
|
||||
...defaultSettings(),
|
||||
rememberToken: true,
|
||||
token: "legacy-value"
|
||||
};
|
||||
|
||||
expect(restorePersistedSettings(input).token).toBe(input.token);
|
||||
expect(protectPersistedSettings(restorePersistedSettings(input)).token).not.toBe(input.token);
|
||||
});
|
||||
|
||||
it("does not persist provider values when encryption is unavailable", () => {
|
||||
configureCredentialProtector(createProtector(false));
|
||||
const persisted = protectPersistedSettings({
|
||||
...defaultSettings(),
|
||||
rememberToken: true,
|
||||
token: "ephemeral-value"
|
||||
});
|
||||
|
||||
expect(persisted.token).toBe("");
|
||||
});
|
||||
|
||||
it("removes persisted provider values when remembering is disabled", () => {
|
||||
const persisted = protectPersistedSettings({
|
||||
...defaultSettings(),
|
||||
rememberToken: false,
|
||||
token: "ephemeral-value",
|
||||
megaLogin: "account@example.invalid",
|
||||
megaPassword: "password-value"
|
||||
});
|
||||
|
||||
expect(persisted.token).toBe("");
|
||||
expect(persisted.megaLogin).toBe("");
|
||||
expect(persisted.megaPassword).toBe("");
|
||||
});
|
||||
|
||||
it("projects only masked presence metadata into renderer settings", () => {
|
||||
const input = {
|
||||
...defaultSettings(),
|
||||
rememberToken: true,
|
||||
token: "value-to-protect",
|
||||
megaDebridApiCredentials: "account@example.invalid:password-value",
|
||||
debridLinkApiKeys: "key-value"
|
||||
};
|
||||
|
||||
const projected = projectSettingsForRenderer(input);
|
||||
const serialized = JSON.stringify(projected);
|
||||
|
||||
expect(serialized).not.toContain(input.token);
|
||||
expect(serialized).not.toContain("account@example.invalid");
|
||||
expect(serialized).not.toContain("password-value");
|
||||
expect(serialized).not.toContain("key-value");
|
||||
expect(projected.token).not.toBe("");
|
||||
expect(projected.megaDebridApiCredentials).not.toBe("");
|
||||
expect(projected.debridLinkApiKeys).not.toBe("");
|
||||
});
|
||||
});
|
||||
+37
-6
@@ -1,15 +1,24 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { AppSettings } from "../src/shared/types";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { configureCredentialProtector } from "../src/main/credential-protection";
|
||||
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
configureCredentialProtector({
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => Buffer.from(value, "utf8").reverse(),
|
||||
decryptString: (value) => Buffer.from(value).reverse().toString("utf8")
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
@@ -163,7 +172,7 @@ describe("settings storage", () => {
|
||||
expect(loaded.allDebridToken).toBe("");
|
||||
});
|
||||
|
||||
it("persists provider credentials when rememberToken is enabled", () => {
|
||||
it("persists provider credentials when rememberToken is enabled", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
@@ -183,8 +192,30 @@ describe("settings storage", () => {
|
||||
expect(loaded.megaLogin).toBe("mega-user");
|
||||
expect(loaded.megaPassword).toBe("mega-pass");
|
||||
expect(loaded.bestToken).toBe("best-token");
|
||||
expect(loaded.allDebridToken).toBe("all-token");
|
||||
});
|
||||
expect(loaded.allDebridToken).toBe("all-token");
|
||||
});
|
||||
|
||||
it("migrates remembered plaintext provider values without retaining plaintext in config backups", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const value = "legacy-value-to-migrate";
|
||||
fs.writeFileSync(paths.configFile, JSON.stringify({
|
||||
...defaultSettings(),
|
||||
rememberToken: true,
|
||||
token: value
|
||||
}), "utf8");
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
const config = fs.readFileSync(paths.configFile, "utf8");
|
||||
const backup = fs.existsSync(`${paths.configFile}.bak`)
|
||||
? fs.readFileSync(`${paths.configFile}.bak`, "utf8")
|
||||
: "";
|
||||
|
||||
expect(loaded.token).toBe(value);
|
||||
expect(config).not.toContain(value);
|
||||
expect(backup).not.toContain(value);
|
||||
});
|
||||
|
||||
it("normalizes invalid enum and numeric values", () => {
|
||||
const normalized = normalizeSettings({
|
||||
|
||||
Reference in New Issue
Block a user