[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
265
electron/bridges/appLockSettingsStore.cjs
Normal file
265
electron/bridges/appLockSettingsStore.cjs
Normal file
@@ -0,0 +1,265 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user