mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
fix: harden app lock system unlock flow
This commit is contained in:
@@ -20,8 +20,8 @@ export function useAppLockBridge() {
|
||||
return netcattyBridge.get()?.requestAppLockUnlock?.(password) ?? { ok: false as const, error: 'incorrect' as const };
|
||||
}, []);
|
||||
|
||||
const requestReset = useCallback(async () => {
|
||||
return netcattyBridge.get()?.requestAppLockReset?.();
|
||||
const requestReset = useCallback(async (currentPassword: string) => {
|
||||
return netcattyBridge.get()?.requestAppLockReset?.(currentPassword);
|
||||
}, []);
|
||||
|
||||
const reportActivity = useCallback(async () => {
|
||||
|
||||
@@ -196,11 +196,14 @@ export function useAppLockState(settings: AppLockSettings) {
|
||||
return result;
|
||||
}, [bridge, refreshRuntimeState, refreshSystemUnlockStatus, setRuntimeState]);
|
||||
|
||||
const reset = useCallback(async () => {
|
||||
const reset = useCallback(async (currentPassword: string) => {
|
||||
if (typeof bridge?.requestAppLockReset !== 'function') {
|
||||
throw new Error('App Lock reset bridge is unavailable');
|
||||
}
|
||||
await bridge.requestAppLockReset();
|
||||
const result = await bridge.requestAppLockReset(currentPassword);
|
||||
if (result && typeof result === 'object' && 'ok' in result && result.ok === false) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
const unlockedAt = Date.now();
|
||||
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
|
||||
await refreshRuntimeState().catch(() => {});
|
||||
|
||||
@@ -206,6 +206,16 @@ test("startup-locked gate reveals children after hidden app lock reset", async (
|
||||
|
||||
assert.equal(dom.document.getElementById("reset-unlocked-content"), null);
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
assert.ok(input);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "secret");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
|
||||
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
||||
assert.ok(logoButton);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
@@ -221,6 +231,7 @@ test("startup-locked gate reveals children after hidden app lock reset", async (
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(bridgeHarness.getResetCount(), 1);
|
||||
assert.deepEqual(bridgeHarness.getResetAttempts(), ["secret"]);
|
||||
assert.equal(bridgeHarness.getRuntimeState().locked, false);
|
||||
assert.equal(dom.document.getElementById("reset-unlocked-content")?.textContent, "Unlocked");
|
||||
} finally {
|
||||
|
||||
@@ -67,7 +67,7 @@ test("AppLockOverlay shows incorrect-password error and clears it after editing"
|
||||
test("AppLockOverlay reveals reset action after clicking Netcatty logo five times", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let resetCount = 0;
|
||||
const resetAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
@@ -78,14 +78,24 @@ test("AppLockOverlay reveals reset action after clicking Netcatty logo five time
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
onResetAppLock: async () => {
|
||||
resetCount += 1;
|
||||
onResetAppLock: async (currentPassword) => {
|
||||
resetAttempts.push(currentPassword);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
assert.ok(input);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "secret");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Reset App Lock/i);
|
||||
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
||||
assert.ok(logoButton);
|
||||
@@ -104,7 +114,7 @@ test("AppLockOverlay reveals reset action after clicking Netcatty logo five time
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(resetCount, 1);
|
||||
assert.deepEqual(resetAttempts, ["secret"]);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
@@ -152,7 +162,7 @@ test("AppLockOverlay reset controls do not submit the unlock form", async () =>
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let unlockCount = 0;
|
||||
let resetCount = 0;
|
||||
const resetAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
@@ -166,14 +176,24 @@ test("AppLockOverlay reset controls do not submit the unlock form", async () =>
|
||||
unlockCount += 1;
|
||||
return { ok: false, error: "incorrect" as const };
|
||||
},
|
||||
onResetAppLock: async () => {
|
||||
resetCount += 1;
|
||||
onResetAppLock: async (currentPassword) => {
|
||||
resetAttempts.push(currentPassword);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
assert.ok(input);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "secret");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /forgot password/i);
|
||||
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
||||
assert.ok(logoButton);
|
||||
@@ -188,7 +208,7 @@ test("AppLockOverlay reset controls do not submit the unlock form", async () =>
|
||||
await dispatchDomEvent(cancelButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
assert.equal(unlockCount, 0);
|
||||
assert.equal(resetCount, 0);
|
||||
assert.deepEqual(resetAttempts, []);
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
@@ -202,7 +222,7 @@ test("AppLockOverlay reset controls do not submit the unlock form", async () =>
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(unlockCount, 0);
|
||||
assert.equal(resetCount, 1);
|
||||
assert.deepEqual(resetAttempts, ["secret"]);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
|
||||
@@ -21,7 +21,7 @@ interface AppLockOverlayProps {
|
||||
onUnlock: (password: string) => Promise<AppLockUnlockResult>;
|
||||
systemUnlockStatus?: AppLockSystemUnlockStatus;
|
||||
onSystemUnlock?: () => Promise<AppLockSystemUnlockResult>;
|
||||
onResetAppLock: () => Promise<void>;
|
||||
onResetAppLock: (currentPassword: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function getAppLockReasonMessageKey(reason: AppLockReason | null): string {
|
||||
@@ -135,7 +135,7 @@ export const AppLockOverlay: React.FC<AppLockOverlayProps> = ({
|
||||
setIsResetting(true);
|
||||
setResetError(false);
|
||||
try {
|
||||
await onResetAppLock();
|
||||
await onResetAppLock(password);
|
||||
} catch {
|
||||
setResetError(true);
|
||||
setIsResetting(false);
|
||||
|
||||
@@ -71,6 +71,15 @@ test("app lock system unlock enablement requires current password", () => {
|
||||
assert.match(handlerSource, /currentPassword: enabled \? appLockSystemUnlockPassword : undefined/);
|
||||
});
|
||||
|
||||
test("app lock system unlock toggle still allows disabling when system auth is unavailable", () => {
|
||||
const source = readFileSync(new URL("./SettingsSystemTab.tsx", import.meta.url), "utf8");
|
||||
const appLockSectionStart = source.indexOf('<SectionHeader title={t("settings.appLock.title")} />');
|
||||
const nextSectionStart = source.indexOf("<SectionHeader", appLockSectionStart + 1);
|
||||
const appLockSection = source.slice(appLockSectionStart, nextSectionStart);
|
||||
|
||||
assert.match(appLockSection, /disabled=\{isSavingAppLockSystemUnlock \|\| \(!appLockSettings\.systemUnlockEnabled && !appLockSystemUnlockStatus\.available\)\}/);
|
||||
});
|
||||
|
||||
test("app lock disable explains that turning it off removes the saved password", () => {
|
||||
const englishLocale = readFileSync(new URL("../../../application/i18n/locales/en/core.ts", import.meta.url), "utf8");
|
||||
|
||||
|
||||
@@ -761,7 +761,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
)}
|
||||
<Toggle
|
||||
checked={appLockSettings.systemUnlockEnabled}
|
||||
disabled={!appLockSystemUnlockStatus.available || isSavingAppLockSystemUnlock}
|
||||
disabled={isSavingAppLockSystemUnlock || (!appLockSettings.systemUnlockEnabled && !appLockSystemUnlockStatus.available)}
|
||||
ariaLabel={t("settings.appLock.systemUnlock.label").replace("{label}", appLockSystemUnlockStatus.label)}
|
||||
onChange={(enabled) => void handleAppLockSystemUnlockChange(enabled)}
|
||||
/>
|
||||
|
||||
@@ -44,6 +44,7 @@ export function createAppLockBridgeHarness(options: HarnessOptions) {
|
||||
let systemUnlockResult = options.systemUnlockResult ?? { ok: true as const };
|
||||
let systemUnlockCount = 0;
|
||||
let resetCount = 0;
|
||||
const resetAttempts: string[] = [];
|
||||
let runtimeFetchCount = 0;
|
||||
|
||||
const emitRuntimeState = () => {
|
||||
@@ -84,8 +85,11 @@ export function createAppLockBridgeHarness(options: HarnessOptions) {
|
||||
});
|
||||
return { ok: true } satisfies UnlockResult;
|
||||
},
|
||||
requestAppLockReset: async () => {
|
||||
requestAppLockReset: async (currentPassword) => {
|
||||
resetCount += 1;
|
||||
resetAttempts.push(currentPassword);
|
||||
if (!currentPassword) return { ok: false, error: "empty-current" };
|
||||
if (currentPassword !== unlockPassword) return { ok: false, error: "incorrect" };
|
||||
setRuntimeState({
|
||||
initialized: true,
|
||||
locked: false,
|
||||
@@ -96,6 +100,7 @@ export function createAppLockBridgeHarness(options: HarnessOptions) {
|
||||
return {
|
||||
enabled: false,
|
||||
timeoutMinutes: 15,
|
||||
systemUnlockEnabled: false,
|
||||
passwordVerifier: null,
|
||||
};
|
||||
},
|
||||
@@ -178,6 +183,9 @@ export function createAppLockBridgeHarness(options: HarnessOptions) {
|
||||
getResetCount() {
|
||||
return resetCount;
|
||||
},
|
||||
getResetAttempts() {
|
||||
return [...resetAttempts];
|
||||
},
|
||||
getSystemUnlockCount() {
|
||||
return systemUnlockCount;
|
||||
},
|
||||
|
||||
@@ -383,8 +383,11 @@ function createAppLockController({
|
||||
return saved;
|
||||
}
|
||||
|
||||
async function requestReset() {
|
||||
async function requestReset(currentPassword) {
|
||||
const current = getSettings();
|
||||
const verified = await verifyCurrentPassword(current, currentPassword);
|
||||
if (verified !== true) return verified;
|
||||
|
||||
const saved = await saveSettings({
|
||||
enabled: false,
|
||||
timeoutMinutes: current.timeoutMinutes,
|
||||
@@ -547,7 +550,8 @@ function createAppLockController({
|
||||
ipcMain.handle("netcatty:appLock:requestEnable", () => requestEnable());
|
||||
ipcMain.handle("netcatty:appLock:requestDisable", (_event, currentPassword) =>
|
||||
requestDisable(currentPassword));
|
||||
ipcMain.handle("netcatty:appLock:requestReset", () => requestReset());
|
||||
ipcMain.handle("netcatty:appLock:requestReset", (_event, currentPassword) =>
|
||||
requestReset(currentPassword));
|
||||
ipcMain.handle("netcatty:appLock:requestPasswordChange", (_event, input) =>
|
||||
requestPasswordChange(input));
|
||||
ipcMain.handle("netcatty:appLock:setLocked", (_event, reason) => setLocked(reason));
|
||||
|
||||
@@ -571,6 +571,24 @@ test("disabling app lock removes the saved password verifier", async () => {
|
||||
assert.equal(saved.passwordVerifier, null);
|
||||
});
|
||||
|
||||
test("resetting app lock requires the current password before clearing the verifier", async () => {
|
||||
const { controller, runtimeBridge } = await createControllerHarness();
|
||||
await controller.requestPasswordChange({ nextPassword: "alpha" });
|
||||
await controller.requestEnable();
|
||||
controller.setLocked("manual");
|
||||
|
||||
assert.deepEqual(
|
||||
await controller.requestReset(),
|
||||
{ ok: false, error: "empty-current" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await controller.requestReset("wrong"),
|
||||
{ ok: false, error: "incorrect" },
|
||||
);
|
||||
assert.equal(controller.getSettings().passwordVerifier !== null, true);
|
||||
assert.equal(runtimeBridge.getState().locked, true);
|
||||
});
|
||||
|
||||
test("resetting app lock clears the verifier, unlocks runtime, and broadcasts settings and runtime", async () => {
|
||||
const { controller, runtimeBridge, windows } = await createControllerHarness();
|
||||
await controller.requestPasswordChange({ nextPassword: "alpha" });
|
||||
@@ -580,7 +598,7 @@ test("resetting app lock clears the verifier, unlocks runtime, and broadcasts se
|
||||
win.sent.length = 0;
|
||||
}
|
||||
|
||||
const saved = await controller.requestReset();
|
||||
const saved = await controller.requestReset("alpha");
|
||||
|
||||
assert.equal(saved.enabled, false);
|
||||
assert.equal(saved.passwordVerifier, null);
|
||||
|
||||
@@ -492,8 +492,8 @@ function createPreloadApi(ctx) {
|
||||
requestAppLockEnable: () => ipcRenderer.invoke("netcatty:appLock:requestEnable"),
|
||||
requestAppLockDisable: (currentPassword) =>
|
||||
ipcRenderer.invoke("netcatty:appLock:requestDisable", currentPassword),
|
||||
requestAppLockReset: () =>
|
||||
ipcRenderer.invoke("netcatty:appLock:requestReset"),
|
||||
requestAppLockReset: (currentPassword) =>
|
||||
ipcRenderer.invoke("netcatty:appLock:requestReset", currentPassword),
|
||||
requestAppLockPasswordChange: (input) =>
|
||||
ipcRenderer.invoke("netcatty:appLock:requestPasswordChange", input),
|
||||
setAppLockRuntimeLocked: (reason) =>
|
||||
|
||||
@@ -72,7 +72,10 @@ function beforePackCursorSdk(context = {}) {
|
||||
ensureCursor({ projectDir, platform });
|
||||
const buildHelper = context.buildWindowsHelloHelper || buildWindowsHelloHelper;
|
||||
if (platform === "win32") {
|
||||
buildHelper(projectDir);
|
||||
const result = buildHelper({ projectDir, platform });
|
||||
if (result?.skipped) {
|
||||
throw new Error(`Windows Hello helper was not built: ${result.reason || "unknown"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ test("beforePackCursorSdk builds Windows Hello helper only for Windows packages"
|
||||
buildWindowsHelloHelper: (projectDir) => calls.push(projectDir),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [process.cwd()]);
|
||||
assert.deepEqual(calls, [{ projectDir: process.cwd(), platform: "win32" }]);
|
||||
|
||||
beforePackCursorSdk({
|
||||
appDir: process.cwd(),
|
||||
@@ -81,5 +81,17 @@ test("beforePackCursorSdk builds Windows Hello helper only for Windows packages"
|
||||
buildWindowsHelloHelper: (projectDir) => calls.push(projectDir),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [process.cwd()]);
|
||||
assert.deepEqual(calls, [{ projectDir: process.cwd(), platform: "win32" }]);
|
||||
});
|
||||
|
||||
test("beforePackCursorSdk fails Windows packaging when Windows Hello helper build is skipped", () => {
|
||||
assert.throws(
|
||||
() => beforePackCursorSdk({
|
||||
appDir: process.cwd(),
|
||||
electronPlatformName: "win32",
|
||||
ensureCursorSdkPlatformPackages: () => [],
|
||||
buildWindowsHelloHelper: () => ({ skipped: true, reason: "compiler-unavailable" }),
|
||||
}),
|
||||
/Windows Hello helper was not built: compiler-unavailable/,
|
||||
);
|
||||
});
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ declare global {
|
||||
setAppLockTimeoutMinutes?(timeoutMinutes: number): Promise<AppLockSettings>;
|
||||
requestAppLockEnable?(): Promise<AppLockSettings | AppLockSettingsMutationError>;
|
||||
requestAppLockDisable?(currentPassword: string): Promise<AppLockSettings | AppLockSettingsMutationError>;
|
||||
requestAppLockReset?(): Promise<AppLockSettings>;
|
||||
requestAppLockReset?(currentPassword: string): Promise<AppLockSettings | AppLockSettingsMutationError>;
|
||||
requestAppLockPasswordChange?(input: {
|
||||
currentPassword?: string;
|
||||
nextPassword: string;
|
||||
|
||||
Reference in New Issue
Block a user