fix: harden app lock system unlock flow

This commit is contained in:
Zheng Liu
2026-06-24 14:42:49 +08:00
parent f6a49dbf0a
commit 0ea707f70f
14 changed files with 114 additions and 26 deletions
+2 -2
View File
@@ -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 () => {
+5 -2
View File
@@ -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(() => {});
+11
View File
@@ -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 {
+29 -9
View File
@@ -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();
+2 -2
View File
@@ -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;
},
+6 -2
View File
@@ -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));
+19 -1
View File
@@ -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);
+2 -2
View File
@@ -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) =>
+4 -1
View File
@@ -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"}`);
}
}
}
+14 -2
View File
@@ -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
View File
@@ -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;