From 0ea707f70f763dfd00fdcd7cf78b57d29fe13ece Mon Sep 17 00:00:00 2001 From: Zheng Liu Date: Wed, 24 Jun 2026 14:42:49 +0800 Subject: [PATCH] fix: harden app lock system unlock flow --- application/state/useAppLockBridge.ts | 4 +- application/state/useAppLockState.ts | 7 +++- components/AppLockGate.runtime.test.tsx | 11 ++++++ components/AppLockOverlay.runtime.test.tsx | 38 ++++++++++++++----- components/AppLockOverlay.tsx | 4 +- .../settings/tabs/SettingsSystemTab.test.ts | 9 +++++ .../settings/tabs/SettingsSystemTab.tsx | 2 +- .../createAppLockBridgeHarness.ts | 10 ++++- electron/bridges/appLockRuntimeBridge.cjs | 8 +++- .../bridges/appLockRuntimeBridge.test.cjs | 20 +++++++++- electron/preload/api.cjs | 4 +- scripts/beforePackCursorSdk.cjs | 5 ++- scripts/beforePackCursorSdk.test.cjs | 16 +++++++- types/global/netcatty-bridge-sync.d.ts | 2 +- 14 files changed, 114 insertions(+), 26 deletions(-) diff --git a/application/state/useAppLockBridge.ts b/application/state/useAppLockBridge.ts index 28282a686..4181b0e65 100644 --- a/application/state/useAppLockBridge.ts +++ b/application/state/useAppLockBridge.ts @@ -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 () => { diff --git a/application/state/useAppLockState.ts b/application/state/useAppLockState.ts index 724e418b3..01aba3972 100644 --- a/application/state/useAppLockState.ts +++ b/application/state/useAppLockState.ts @@ -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(() => {}); diff --git a/components/AppLockGate.runtime.test.tsx b/components/AppLockGate.runtime.test.tsx index 3389ff836..4394f4ed1 100644 --- a/components/AppLockGate.runtime.test.tsx +++ b/components/AppLockGate.runtime.test.tsx @@ -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 { diff --git a/components/AppLockOverlay.runtime.test.tsx b/components/AppLockOverlay.runtime.test.tsx index 9d342f8dc..be01318cb 100644 --- a/components/AppLockOverlay.runtime.test.tsx +++ b/components/AppLockOverlay.runtime.test.tsx @@ -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(); diff --git a/components/AppLockOverlay.tsx b/components/AppLockOverlay.tsx index 8793b11ac..031fd3603 100644 --- a/components/AppLockOverlay.tsx +++ b/components/AppLockOverlay.tsx @@ -21,7 +21,7 @@ interface AppLockOverlayProps { onUnlock: (password: string) => Promise; systemUnlockStatus?: AppLockSystemUnlockStatus; onSystemUnlock?: () => Promise; - onResetAppLock: () => Promise; + onResetAppLock: (currentPassword: string) => Promise; } export function getAppLockReasonMessageKey(reason: AppLockReason | null): string { @@ -135,7 +135,7 @@ export const AppLockOverlay: React.FC = ({ setIsResetting(true); setResetError(false); try { - await onResetAppLock(); + await onResetAppLock(password); } catch { setResetError(true); setIsResetting(false); diff --git a/components/settings/tabs/SettingsSystemTab.test.ts b/components/settings/tabs/SettingsSystemTab.test.ts index 3ea17ad4e..b77a2a13b 100644 --- a/components/settings/tabs/SettingsSystemTab.test.ts +++ b/components/settings/tabs/SettingsSystemTab.test.ts @@ -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(''); + const nextSectionStart = source.indexOf(" { const englishLocale = readFileSync(new URL("../../../application/i18n/locales/en/core.ts", import.meta.url), "utf8"); diff --git a/components/settings/tabs/SettingsSystemTab.tsx b/components/settings/tabs/SettingsSystemTab.tsx index f22f9080d..afbe2cbd9 100644 --- a/components/settings/tabs/SettingsSystemTab.tsx +++ b/components/settings/tabs/SettingsSystemTab.tsx @@ -761,7 +761,7 @@ const SettingsSystemTab: React.FC = ({ )} void handleAppLockSystemUnlockChange(enabled)} /> diff --git a/components/test-support/createAppLockBridgeHarness.ts b/components/test-support/createAppLockBridgeHarness.ts index 9a406e360..7b641f471 100644 --- a/components/test-support/createAppLockBridgeHarness.ts +++ b/components/test-support/createAppLockBridgeHarness.ts @@ -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; }, diff --git a/electron/bridges/appLockRuntimeBridge.cjs b/electron/bridges/appLockRuntimeBridge.cjs index 7a673409a..00d6ae81c 100644 --- a/electron/bridges/appLockRuntimeBridge.cjs +++ b/electron/bridges/appLockRuntimeBridge.cjs @@ -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)); diff --git a/electron/bridges/appLockRuntimeBridge.test.cjs b/electron/bridges/appLockRuntimeBridge.test.cjs index b13c8d21e..726760716 100644 --- a/electron/bridges/appLockRuntimeBridge.test.cjs +++ b/electron/bridges/appLockRuntimeBridge.test.cjs @@ -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); diff --git a/electron/preload/api.cjs b/electron/preload/api.cjs index 993f08a4a..24eac5b2d 100644 --- a/electron/preload/api.cjs +++ b/electron/preload/api.cjs @@ -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) => diff --git a/scripts/beforePackCursorSdk.cjs b/scripts/beforePackCursorSdk.cjs index ff3bdfe64..5c10df99f 100644 --- a/scripts/beforePackCursorSdk.cjs +++ b/scripts/beforePackCursorSdk.cjs @@ -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"}`); + } } } diff --git a/scripts/beforePackCursorSdk.test.cjs b/scripts/beforePackCursorSdk.test.cjs index f91333af5..c759b78db 100644 --- a/scripts/beforePackCursorSdk.test.cjs +++ b/scripts/beforePackCursorSdk.test.cjs @@ -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/, + ); }); diff --git a/types/global/netcatty-bridge-sync.d.ts b/types/global/netcatty-bridge-sync.d.ts index 4e36c8298..0eb515844 100644 --- a/types/global/netcatty-bridge-sync.d.ts +++ b/types/global/netcatty-bridge-sync.d.ts @@ -76,7 +76,7 @@ declare global { setAppLockTimeoutMinutes?(timeoutMinutes: number): Promise; requestAppLockEnable?(): Promise; requestAppLockDisable?(currentPassword: string): Promise; - requestAppLockReset?(): Promise; + requestAppLockReset?(currentPassword: string): Promise; requestAppLockPasswordChange?(input: { currentPassword?: string; nextPassword: string;