Files
NetMesh/electron/bridges/appLockSettingsStore.cjs

266 lines
7.9 KiB
JavaScript
Raw Normal View History

const {
pbkdf2,
randomBytes,
timingSafeEqual,
} = require("node:crypto");
const APP_LOCK_TIMEOUT_OPTIONS_MINUTES = [0, 1, 5, 15, 30, 60];
const DEFAULT_APP_LOCK_SETTINGS = Object.freeze({
enabled: false,
timeoutMinutes: 15,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: null,
});
const APP_LOCK_VERIFIER_VERSION = 1;
const APP_LOCK_ALGORITHM = "PBKDF2-SHA256";
const APP_LOCK_HASH_ITERATIONS = 210000;
const APP_LOCK_MIN_ITERATIONS = 100000;
const APP_LOCK_SALT_BYTES = 16;
const APP_LOCK_HASH_BYTES = 32;
function cloneSettings(settings) {
return {
enabled: settings.enabled === true,
timeoutMinutes: normalizeAppLockTimeoutMinutes(settings.timeoutMinutes),
systemUnlockEnabled:
settings.systemUnlockEnabled === true &&
settings.enabled === true &&
settings.passwordVerifier !== null,
systemUnlockAutoPromptEnabled:
settings.systemUnlockAutoPromptEnabled === true &&
settings.systemUnlockEnabled === true &&
settings.enabled === true &&
settings.passwordVerifier !== null,
passwordVerifier: settings.passwordVerifier
? {
version: settings.passwordVerifier.version,
algorithm: settings.passwordVerifier.algorithm,
iterations: settings.passwordVerifier.iterations,
salt: settings.passwordVerifier.salt,
hash: settings.passwordVerifier.hash,
}
: null,
};
}
function isRecord(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function decodeBase64Bytes(value) {
if (typeof value !== "string" || value.length === 0) return null;
try {
const bytes = Buffer.from(value, "base64");
if (bytes.length === 0) return null;
if (bytes.toString("base64") !== value) return null;
return bytes;
} catch {
return null;
}
}
function normalizeAppLockTimeoutMinutes(input) {
const value = typeof input === "string" && input.trim() !== "" ? Number(input) : input;
return APP_LOCK_TIMEOUT_OPTIONS_MINUTES.includes(value)
? value
: DEFAULT_APP_LOCK_SETTINGS.timeoutMinutes;
}
function normalizeAppLockPasswordVerifier(input) {
if (!isRecord(input)) return null;
if (input.version !== APP_LOCK_VERIFIER_VERSION) return null;
if (input.algorithm !== APP_LOCK_ALGORITHM) return null;
if (
typeof input.iterations !== "number" ||
!Number.isInteger(input.iterations) ||
input.iterations < APP_LOCK_MIN_ITERATIONS
) {
return null;
}
const saltBytes = decodeBase64Bytes(input.salt);
if (!saltBytes || saltBytes.length !== APP_LOCK_SALT_BYTES) return null;
const hashBytes = decodeBase64Bytes(input.hash);
if (!hashBytes || hashBytes.length !== APP_LOCK_HASH_BYTES) return null;
return {
version: APP_LOCK_VERIFIER_VERSION,
algorithm: APP_LOCK_ALGORITHM,
iterations: input.iterations,
salt: input.salt,
hash: input.hash,
};
}
function derivePasswordHash(password, saltBytes, iterations) {
return new Promise((resolve, reject) => {
pbkdf2(password, saltBytes, iterations, APP_LOCK_HASH_BYTES, "sha256", (error, derivedKey) => {
if (error) {
reject(error);
return;
}
resolve(derivedKey.toString("base64"));
});
});
}
function normalizeAppLockSettings(input) {
if (!isRecord(input)) return cloneSettings(DEFAULT_APP_LOCK_SETTINGS);
const timeoutMinutes = normalizeAppLockTimeoutMinutes(input.timeoutMinutes);
const passwordVerifier = normalizeAppLockPasswordVerifier(input.passwordVerifier);
const enabled = input.enabled === true && passwordVerifier !== null;
const systemUnlockEnabled = input.systemUnlockEnabled === true && enabled;
const systemUnlockAutoPromptEnabled = input.systemUnlockAutoPromptEnabled === true && systemUnlockEnabled;
return {
enabled,
timeoutMinutes,
systemUnlockEnabled,
systemUnlockAutoPromptEnabled,
passwordVerifier,
};
}
function canLockFromSettings(settings) {
const normalized = normalizeAppLockSettings(settings);
return normalized.enabled === true && normalized.passwordVerifier !== null;
}
/**
* Hide-to-tray / app-hide locks are automatic. timeoutMinutes === 0 is
* "Never lock automatically", so those background locks stay off. Startup
* and manual locks still apply whenever canLockFromSettings is true.
*/
function shouldLockOnBackgroundHide(settings) {
const normalized = normalizeAppLockSettings(settings);
return canLockFromSettings(normalized) && normalized.timeoutMinutes > 0;
}
async function createAppLockPasswordVerifier(password) {
if (typeof password !== "string" || password.length === 0) {
throw new Error("App lock password is required");
}
const saltBytes = randomBytes(APP_LOCK_SALT_BYTES);
return {
version: APP_LOCK_VERIFIER_VERSION,
algorithm: APP_LOCK_ALGORITHM,
iterations: APP_LOCK_HASH_ITERATIONS,
salt: saltBytes.toString("base64"),
hash: await derivePasswordHash(password, saltBytes, APP_LOCK_HASH_ITERATIONS),
};
}
async function verifyAppLockPassword(password, verifier) {
const normalized = normalizeAppLockPasswordVerifier(verifier);
if (typeof password !== "string" || password.length === 0 || !normalized) {
return false;
}
const saltBytes = decodeBase64Bytes(normalized.salt);
const hashBytes = decodeBase64Bytes(normalized.hash);
if (!saltBytes || !hashBytes) return false;
const candidateBytes = Buffer.from(
await derivePasswordHash(password, saltBytes, normalized.iterations),
"base64",
);
if (candidateBytes.length !== hashBytes.length) return false;
return timingSafeEqual(candidateBytes, hashBytes);
}
function createAppLockSettingsStore({
filePath,
readFile,
writeFile,
rename,
}) {
if (!filePath) {
throw new Error("createAppLockSettingsStore requires filePath");
}
if (typeof readFile !== "function") {
throw new Error("createAppLockSettingsStore requires readFile");
}
if (typeof writeFile !== "function") {
throw new Error("createAppLockSettingsStore requires writeFile");
}
let snapshot = cloneSettings(DEFAULT_APP_LOCK_SETTINGS);
// Serialize load/save so concurrent mutations cannot race on the same .tmp
// path or overwrite each other with stale snapshots (Codex P2).
let writeChain = Promise.resolve();
async function load() {
let raw;
try {
raw = await readFile(filePath, "utf8");
} catch (err) {
if (err && err.code === "ENOENT") {
snapshot = cloneSettings(DEFAULT_APP_LOCK_SETTINGS);
return cloneSettings(snapshot);
}
throw err;
}
try {
snapshot = normalizeAppLockSettings(JSON.parse(String(raw)));
} catch {
snapshot = cloneSettings(DEFAULT_APP_LOCK_SETTINGS);
}
return cloneSettings(snapshot);
}
async function save(nextSettings) {
const run = async () => {
const normalized = normalizeAppLockSettings(nextSettings);
const payload = `${JSON.stringify(normalized, null, 2)}\n`;
// Atomic replace: write unique temp then rename so a crash mid-write cannot
// leave a truncated file that load() would treat as DEFAULT (Codex P2).
if (typeof rename === "function") {
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmpPath, payload, { mode: 0o600 });
await rename(tmpPath, filePath);
} else {
await writeFile(filePath, payload, { mode: 0o600 });
}
snapshot = normalized;
return cloneSettings(snapshot);
};
const pending = writeChain.then(run, run);
writeChain = pending.then(() => {}, () => {});
return pending;
}
function getSnapshot() {
return cloneSettings(snapshot);
}
return {
load,
save,
getSnapshot,
};
}
module.exports = {
APP_LOCK_TIMEOUT_OPTIONS_MINUTES,
DEFAULT_APP_LOCK_SETTINGS,
canLockFromSettings,
shouldLockOnBackgroundHide,
createAppLockPasswordVerifier,
createAppLockSettingsStore,
normalizeAppLockPasswordVerifier,
normalizeAppLockSettings,
normalizeAppLockTimeoutMinutes,
verifyAppLockPassword,
};