From a419af5ef5df68a356656b3d650be195dc2f6b80 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 10:03:51 +0000 Subject: [PATCH] Passkeys: hide WebAuthn and explain when a page asks for it; build 5 WKWebView runs WebAuthn only for apps with Apple's web-browser entitlement, so passkeys and security keys always fail here. Pages still offered them: Google's 2-Step Verification asked for a passkey or a phone nearby and failed with "make sure Bluetooth is on". - The polyfill deletes window.PublicKeyCredential, so sign-in pages that check for it offer another method. - navigator.credentials.get/create with publicKey reject at once with NotAllowedError and, once per page, post {type: "webauthn"}. Passkey autofill (conditional mediation) fails quietly. Other credential requests pass through. - The app answers with a toast saying to choose another way to sign in, shown over the sign-in popup when that's where the request came from. - The GeForce NOW and Figma sign-in tips and the README say the same. - Tests: four polyfill tests on an https page (WebAuthn needs a secure context). Three fail without this change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W5tzx8GXgMz9DMzc5BF4ND --- .../PointerLocker/BrowserViewController.swift | 14 ++++ Apps/PointerLocker/PopupViewController.swift | 3 + .../Resources/pointerlock-polyfill.js | 32 ++++++++ .../PointerLocker/Services/FigmaProfile.swift | 2 +- .../Services/GeForceNowProfile.swift | 2 +- README.md | 10 +++ project.yml | 2 +- tests/polyfill.test.mjs | 74 +++++++++++++++++++ 8 files changed, 136 insertions(+), 3 deletions(-) diff --git a/Apps/PointerLocker/BrowserViewController.swift b/Apps/PointerLocker/BrowserViewController.swift index 48b1fba..9a1d3f0 100644 --- a/Apps/PointerLocker/BrowserViewController.swift +++ b/Apps/PointerLocker/BrowserViewController.swift @@ -348,6 +348,12 @@ final class BrowserViewController: UIViewController { // MARK: - Page messages extension BrowserViewController: WKScriptMessageHandler { + /// Shown when a page asks for a passkey or security key (the polyfill + /// hides WebAuthn, which WKWebView can't run without Apple's browser + /// entitlement). + static let passkeyNotice = "Passkeys and security keys don't work in Mouselook. " + + "Choose another way to sign in, such as a code from your authenticator app or a prompt on your phone." + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { guard message.frameInfo.isMainFrame, let body = message.body as? [String: Any], @@ -361,6 +367,14 @@ extension BrowserViewController: WKScriptMessageHandler { case "health": health.receive(check: body["check"] as? String ?? "", result: body["result"] as? String ?? "", code: body["code"] as? String ?? "", host: webView.url?.host ?? "", locked: pageWantsLock) + case "webauthn": + // Sign-in often runs in a popup sheet, which would hide our toast. + if message.webView !== webView, + let popup = (presentedViewController as? UINavigationController)?.viewControllers.first as? PopupViewController { + popup.toast.show(Self.passkeyNotice) + } else { + toast.show(Self.passkeyNotice) + } default: break } } diff --git a/Apps/PointerLocker/PopupViewController.swift b/Apps/PointerLocker/PopupViewController.swift index c274393..9fca0db 100644 --- a/Apps/PointerLocker/PopupViewController.swift +++ b/Apps/PointerLocker/PopupViewController.swift @@ -5,6 +5,8 @@ import WebKit /// WebKit requires the returned web view to use the configuration it passed us. final class PopupViewController: UIViewController, WKUIDelegate { let webView: WKWebView + /// Messages about this popup's page, shown over it rather than behind it. + let toast = ToastView() init(configuration: WKWebViewConfiguration, userAgent: String?) { webView = WKWebView(frame: .zero, configuration: configuration) @@ -23,6 +25,7 @@ final class PopupViewController: UIViewController, WKUIDelegate { override func viewDidLoad() { super.viewDidLoad() + toast.install(in: view) navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .close, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }) diff --git a/Apps/PointerLocker/Resources/pointerlock-polyfill.js b/Apps/PointerLocker/Resources/pointerlock-polyfill.js index 160b703..3c0e2a6 100644 --- a/Apps/PointerLocker/Resources/pointerlock-polyfill.js +++ b/Apps/PointerLocker/Resources/pointerlock-polyfill.js @@ -13,6 +13,8 @@ // page -> native: webkit.messageHandlers.pointerLocker.postMessage({type}) // {type: "lock"} page called requestPointerLock() // {type: "unlock"} lock ended (exitPointerLock, hold-Esc, detach, ...) +// {type: "webauthn"} the page asked for a passkey or security key, which +// WKWebView can't provide here (once per page) // native -> page: window.__pointerLocker.batch([[kind, ...args], ...]) // ["m", dx, dy] relative movement (CSS px, y down) // ["b", button, down] button 0 = primary, 1 = middle, 2 = secondary @@ -50,6 +52,36 @@ try { return native.element ? native.element.call(document) : null; } catch (_) { return null; } }; + // --------------------------------------------------------------------- + // Passkeys and security keys (WebAuthn). WKWebView runs WebAuthn only for + // apps with Apple's web-browser entitlement, which this app doesn't have, + // so every attempt fails. Sign-in pages that see the API keep offering it: + // Google's 2-Step Verification asks for a passkey or a phone nearby and + // fails with "make sure Bluetooth is on". Hide the API so pages offer + // another method, and if one calls it anyway, fail at once and tell the + // app, which explains what to use instead. + // --------------------------------------------------------------------- + try { delete window.PublicKeyCredential; } catch (_) {} + const credentials = navigator.credentials; + if (credentials) { + let told = false; + for (const name of ["get", "create"]) { + const original = credentials[name]; + if (typeof original !== "function") continue; + credentials[name] = function (options) { + if (!options || !options.publicKey) return original.apply(this, arguments); + // Passkey autofill ("conditional" mediation) runs quietly on page + // load; only an explicit request is worth explaining. + if (options.mediation !== "conditional" && !told) { + told = true; + post({ type: "webauthn" }); + } + return Promise.reject(new DOMException( + "Passkeys and security keys aren't available in this app.", "NotAllowedError")); + }; + } + } + // --------------------------------------------------------------------- // Browser identity. Services pick their client by device: GeForce NOW sends // an iPad (touch points, "iPad" platform) to its touch/PWA flow, which never diff --git a/Apps/PointerLocker/Services/FigmaProfile.swift b/Apps/PointerLocker/Services/FigmaProfile.swift index af3755c..ad39257 100644 --- a/Apps/PointerLocker/Services/FigmaProfile.swift +++ b/Apps/PointerLocker/Services/FigmaProfile.swift @@ -38,7 +38,7 @@ extension ServiceProfile { id: "session", symbol: "person.crop.circle.badge.checkmark", title: "Sign in with email and password", - detail: "Passkeys don't work in this app. You'll stay signed in after the first time." + detail: "Passkeys and security keys don't work in this app. If two-step sign-in asks for one, choose another way, such as a code. You'll stay signed in after the first time." ), ], wording: ServiceWording( diff --git a/Apps/PointerLocker/Services/GeForceNowProfile.swift b/Apps/PointerLocker/Services/GeForceNowProfile.swift index 4ba022c..5b2b791 100644 --- a/Apps/PointerLocker/Services/GeForceNowProfile.swift +++ b/Apps/PointerLocker/Services/GeForceNowProfile.swift @@ -39,7 +39,7 @@ extension ServiceProfile { id: "session", symbol: "person.crop.circle.badge.checkmark", title: "Sign in with a password or email code", - detail: "Passkeys don't work in this app. You'll stay signed in after the first time." + detail: "Passkeys and security keys don't work in this app. If two-step sign-in asks for one, choose another way, such as a code. You'll stay signed in after the first time." ), ], wording: ServiceWording( diff --git a/README.md b/README.md index c0f335d..26c3997 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,16 @@ builds and tests the app. ## Known risks and what to try +- **Passkeys and security keys.** WKWebView runs WebAuthn only for apps + with Apple's web-browser entitlement, which Mouselook doesn't have, so + passkeys and security keys can't work. Google's 2-Step Verification used + to offer them anyway and fail with "make sure Bluetooth is on". The + polyfill now hides `PublicKeyCredential` so sign-in pages offer another + method, and turns any WebAuthn request that still comes into an immediate + `NotAllowedError` plus a toast that says to choose another way, such as a + code from an authenticator app. The real fix would be the + `com.apple.developer.web-browser` entitlement, which requires Mouselook to + qualify as a default-browser app. - **`event.isTrusted`.** Synthetic events have `isTrusted === false`. Tested in the simulator (Sept 2026): GeForce NOW doesn't filter on it. Each `__pointerLocker.batch([["m",dx,dy]])` while locked becomes one message on diff --git a/project.yml b/project.yml index 2f8b8f8..3b7ade6 100644 --- a/project.yml +++ b/project.yml @@ -13,7 +13,7 @@ settings: # Change to your own team to build elsewhere. DEVELOPMENT_TEAM: AR9ZHABG8Y # IceBear s.r.o. MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "4" + CURRENT_PROJECT_VERSION: "5" targets: PointerLocker: diff --git a/tests/polyfill.test.mjs b/tests/polyfill.test.mjs index bbc5011..d25ea49 100644 --- a/tests/polyfill.test.mjs +++ b/tests/polyfill.test.mjs @@ -680,6 +680,80 @@ await test("the polyfill's own lock error still reaches the page while a native await p.close(); }); +// Passkeys and security keys: WKWebView can't run WebAuthn in this app, so +// the polyfill hides it and turns any request into a quick, explained failure. +// WebAuthn exists only in secure contexts, so serve the page from https. +async function webauthnPage(before = "") { + const p = await browser.newPage(); + await p.route("https://signin.test/**", (route) => + route.fulfill({ contentType: "text/html", body: "

sign in

" })); + await p.addInitScript({ content: noNativeLock + stub + identity + before }); + await p.addInitScript({ content: polyfill }); + await p.goto("https://signin.test/"); + return p; +} + +await test("WebAuthn is hidden from the page", async () => { + const p = await webauthnPage(); + const r = await p.evaluate(() => ({ + secure: window.isSecureContext, + type: typeof window.PublicKeyCredential, + present: "PublicKeyCredential" in window, + })); + assert.deepEqual(r, { secure: true, type: "undefined", present: false }); + await p.close(); +}); + +await test("a passkey request fails at once and tells the app, once per page", async () => { + const p = await webauthnPage(); + const r = await p.evaluate(async () => { + const publicKey = { challenge: new Uint8Array(16), timeout: 60000 }; + const errors = []; + for (const call of [() => navigator.credentials.get({ publicKey }), + () => navigator.credentials.create({ publicKey }), + () => navigator.credentials.get({ publicKey })]) { + try { await call(); errors.push("resolved"); } catch (e) { errors.push(e.name); } + } + return { errors, messages: window.__messages }; + }); + assert.deepEqual(r.errors, ["NotAllowedError", "NotAllowedError", "NotAllowedError"]); + assert.deepEqual(r.messages, [{ type: "webauthn" }]); + await p.close(); +}); + +await test("passkey autofill (conditional mediation) fails quietly", async () => { + const p = await webauthnPage(); + const r = await p.evaluate(async () => { + let error; + try { + await navigator.credentials.get({ publicKey: { challenge: new Uint8Array(16) }, mediation: "conditional" }); + } catch (e) { error = e.name; } + return { error, messages: window.__messages }; + }); + assert.equal(r.error, "NotAllowedError"); + assert.deepEqual(r.messages, []); + await p.close(); +}); + +await test("other credential requests still reach the browser", async () => { + // Stand in for the browser's own get(), installed before the polyfill wraps it. + const p = await webauthnPage(` + window.__seen = []; + CredentialsContainer.prototype.get = function (options) { + window.__seen.push(Object.keys(options)); + return Promise.resolve(null); + };`); + const r = await p.evaluate(async () => ({ + result: await navigator.credentials.get({ password: true }), + seen: window.__seen, + messages: window.__messages, + })); + assert.equal(r.result, null); + assert.deepEqual(r.seen, [["password"]]); + assert.deepEqual(r.messages, []); + await p.close(); +}); + await browser.close(); if (failures) { console.log(`\n${failures} test(s) failed`);