From 2d943ef610505083fe018f492b1a5e4aa5519559 Mon Sep 17 00:00:00 2001 From: "trop[bot]" <37223003+trop[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 11:15:13 -0400 Subject: [PATCH] feat: support WebAuthn Touch ID platform authenticator on macOS (#51411) * feat: support WebAuthn Touch ID platform authenticator on macOS Adds `app.configureWebAuthn({ touchID: { keychainAccessGroup } })` to enable the Secure Enclave platform authenticator for `navigator.credentials`. Credentials are stored under the app-supplied keychain access group with a per-session metadata secret that is generated on first use and persisted in prefs. Also introduces `ElectronAuthenticatorRequestClientDelegate` and wires it via `ContentBrowserClient::GetWebAuthenticationRequestDelegate()` so that discoverable-credential `get()` calls with multiple matches emit a new `select-webauthn-account` session event instead of DCHECK-failing in the base delegate. If no listener is registered (or the callback is invoked with no credential), the request is cancelled with NotAllowedError rather than silently auto-selecting. Tests use the DevTools virtual authenticator so the account-selection flow is exercised in CI without entitlements or real hardware. Co-authored-by: Samuel Attard * fix: register request delegate as FidoRequestHandlerBase observer The base AuthenticatorRequestClientDelegate::StartObserving() is a no-op, so observer() on the request handler stayed null. MakeCredentialRequestHandler:: SpecializeRequestForAuthenticator dereferences observer()->SupportsPIN() when residentKey is 'preferred', crashing with SEGV when a real FIDO2 HID key is dispatched. Override StartObserving/StopObserving to register via a ScopedObservation like ChromeAuthenticatorRequestDelegate does. Added a virtual-authenticator regression test for create() with residentKey: 'preferred'. Co-authored-by: Samuel Attard * chore: update copyright attribution for new webauthn files Co-authored-by: Samuel Attard * fix: address review feedback on webauthn account-select event - Encode credentialId and userHandle as URL-safe base64 without padding so the values match PublicKeyCredential.id from navigator.credentials.get() byte-for-byte; tests now assert the equality rather than transcoding. - Cancel the pending request when the listener invokes the callback with a credentialId that does not match any account, instead of leaving the request hanging while the listener retries. The TypeError still surfaces so the misuse remains visible to the developer. - DCHECK that the Touch ID config helpers run on the UI thread, encoding the threading invariant the read-then-write metadata-secret pref relies on. Co-authored-by: Samuel Attard * fix: oxfmt formatting in webauthn spec Co-authored-by: Samuel Attard * fix: use out-param form of base::Base64UrlEncode Co-authored-by: Samuel Attard * fix: silently cancel webauthn account select on unknown credentialId Throwing back into the listener bubbles up as an unhandled exception in the main process. Match the no-args branch exactly so the listener sees a single consistent failure mode (cancel + NotAllowedError) whether it declines deliberately or by mistake. Co-authored-by: Samuel Attard * chore: node script/lint.js --js --fix --------- Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com> Co-authored-by: Samuel Attard Co-authored-by: Charles Kerr --- BUILD.gn | 1 + docs/api/app.md | 45 ++++ docs/api/session.md | 48 ++++ docs/api/structures/webauthn-account.md | 9 + filenames.auto.gni | 1 + filenames.gni | 2 + shell/browser/api/electron_api_app.cc | 25 ++ shell/browser/api/electron_api_app.h | 2 + shell/browser/electron_browser_client.cc | 10 + shell/browser/electron_browser_client.h | 5 + shell/browser/electron_browser_context.cc | 5 + ...n_authenticator_request_client_delegate.cc | 204 +++++++++++++++ ...on_authenticator_request_client_delegate.h | 81 ++++++ ...electron_authenticator_request_delegate.cc | 55 ++++ .../electron_authenticator_request_delegate.h | 21 ++ shell/common/electron_constants.h | 5 + spec/api-web-authn-spec.ts | 237 ++++++++++++++++++ 17 files changed, 756 insertions(+) create mode 100644 docs/api/structures/webauthn-account.md create mode 100644 shell/browser/webauthn/electron_authenticator_request_client_delegate.cc create mode 100644 shell/browser/webauthn/electron_authenticator_request_client_delegate.h create mode 100644 spec/api-web-authn-spec.ts diff --git a/BUILD.gn b/BUILD.gn index 5276826572..3a4c14b85e 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -509,6 +509,7 @@ source_set("electron_lib") { "//content/public/utility", "//device/bluetooth", "//device/bluetooth/public/cpp", + "//device/fido", "//gin", "//gpu/ipc/client", "//media/capture/mojom:video_capture", diff --git a/docs/api/app.md b/docs/api/app.md index e3b967c328..41e4a5f7f8 100644 --- a/docs/api/app.md +++ b/docs/api/app.md @@ -1233,6 +1233,51 @@ This API must be called after the `ready` event is emitted. [doh-providers]: https://source.chromium.org/chromium/chromium/src/+/main:net/dns/public/doh_provider_entry.cc;l=31?q=%22DohProviderEntry::GetList()%22&ss=chromium%2Fchromium%2Fsrc [RFC8484 § 3]: https://datatracker.ietf.org/doc/html/rfc8484#section-3 +### `app.configureWebAuthn(options)` _macOS_ + +* `options` Object + * `touchID` Object (optional) - Enables the Touch ID / Secure Enclave platform + authenticator for [Web Authentication](https://www.w3.org/TR/webauthn-2/) + requests. + * `keychainAccessGroup` string - The keychain access group that WebAuthn + credentials will be stored under. This value **must** also be present in + your app's `keychain-access-groups` code-signing entitlement, and is + typically of the form `..webauthn`. + +Configures platform authenticators for the Web Authentication API +(`navigator.credentials.create()` / `navigator.credentials.get()`). Until this +is called, `PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()` +resolves to `false` and platform-authenticator requests are not serviced. + +When `touchID` is provided, WebAuthn credentials are stored in the macOS +keychain and bound to this device's Secure Enclave. Electron automatically +generates and persists a per-[`session`](session.md) metadata secret so that +credentials created in one partition are not visible to another. + +```js +const { app } = require('electron') + +app.configureWebAuthn({ + touchID: { + keychainAccessGroup: 'A1B2C3D4E5.com.example.app.webauthn' + } +}) +``` + +With the matching entitlement in your app's `entitlements.plist`: + +```xml +keychain-access-groups + + A1B2C3D4E5.com.example.app.webauthn + +``` + +> [!NOTE] +> Touch ID WebAuthn credentials are device-bound and are not synced via iCloud +> Keychain. They are only available on Macs with a Secure Enclave (Apple +> silicon, or Intel Macs with a T2 chip). + ### `app.disableHardwareAcceleration()` Disables hardware acceleration for current app. diff --git a/docs/api/session.md b/docs/api/session.md index f2134ea416..c81cb24dfa 100644 --- a/docs/api/session.md +++ b/docs/api/session.md @@ -629,6 +629,54 @@ Emitted after `USBDevice.forget()` has been called. This event can be used to help maintain persistent storage of permissions when `setDevicePermissionHandler` is used. +#### Event: 'select-webauthn-account' + +Returns: + +* `event` Event +* `details` Object + * `relyingPartyId` string - The relying party identifier from the WebAuthn request. + * `accounts` [WebAuthnAccount[]](structures/webauthn-account.md) + * `frame` [WebFrameMain](web-frame-main.md) | null - The frame initiating this event. + May be `null` if accessed after the frame has either navigated or been destroyed. +* `callback` Function + * `credentialId` string | null (optional) + +Emitted when a call to `navigator.credentials.get()` resolves multiple +discoverable WebAuthn credentials and the user must choose one. `callback` +should be called with the `credentialId` of the selected account; passing no +arguments — or a `credentialId` that does not match one of the provided +accounts — will cancel the request and the page will receive a +`NotAllowedError`. If no listener is registered for this event, the request is +cancelled with the same error. The credential request remains pending until +the listener invokes the callback, so always invoke it exactly once — typically +from a `try { … } finally { callback(…) }` block. + +On macOS, the Touch ID platform authenticator surfaces accounts via this event +once it has been configured with +[`app.configureWebAuthn`](app.md#appconfigurewebauthnoptions-macos). The event +may also fire on other platforms when a roaming FIDO2 authenticator returns +multiple discoverable credentials. + +```js +const { app, BrowserWindow } = require('electron') + +let win = null + +app.whenReady().then(() => { + app.configureWebAuthn({ + touchID: { keychainAccessGroup: 'A1B2C3D4E5.com.example.app.webauthn' } + }) + + win = new BrowserWindow() + + win.webContents.session.on('select-webauthn-account', (event, details, callback) => { + const selected = details.accounts.find((a) => a.name === 'alice@example.com') + callback(selected?.credentialId) + }) +}) +``` + ### Instance Methods The following methods are available on instances of `Session`: diff --git a/docs/api/structures/webauthn-account.md b/docs/api/structures/webauthn-account.md new file mode 100644 index 0000000000..db11cc6d48 --- /dev/null +++ b/docs/api/structures/webauthn-account.md @@ -0,0 +1,9 @@ +# WebAuthnAccount Object + +* `credentialId` string - URL-safe base64-encoded (no padding) credential ID of + the discoverable credential. Matches `PublicKeyCredential.id` returned by + `navigator.credentials.get()` in the renderer. +* `userHandle` string (optional) - URL-safe base64-encoded (no padding) user + handle (`user.id`) that was provided when the credential was created. +* `name` string (optional) - Human-palatable identifier for the account (for example, an email address or username). +* `displayName` string (optional) - Human-palatable name for the account, intended for display. diff --git a/filenames.auto.gni b/filenames.auto.gni index 0187243f4e..174ce642d5 100644 --- a/filenames.auto.gni +++ b/filenames.auto.gni @@ -171,6 +171,7 @@ auto_filenames = { "docs/api/structures/web-preferences.md", "docs/api/structures/web-request-filter.md", "docs/api/structures/web-source.md", + "docs/api/structures/webauthn-account.md", "docs/api/structures/window-open-handler-response.md", "docs/api/structures/window-session-end-event.md", ] diff --git a/filenames.gni b/filenames.gni index c4bff0487a..6ddb1a8140 100644 --- a/filenames.gni +++ b/filenames.gni @@ -566,6 +566,8 @@ filenames = { "shell/browser/web_view_guest_delegate.h", "shell/browser/web_view_manager.cc", "shell/browser/web_view_manager.h", + "shell/browser/webauthn/electron_authenticator_request_client_delegate.cc", + "shell/browser/webauthn/electron_authenticator_request_client_delegate.h", "shell/browser/webauthn/electron_authenticator_request_delegate.cc", "shell/browser/webauthn/electron_authenticator_request_delegate.h", "shell/browser/window_list.cc", diff --git a/shell/browser/api/electron_api_app.cc b/shell/browser/api/electron_api_app.cc index 0afdd384d8..ae9de2156f 100644 --- a/shell/browser/api/electron_api_app.cc +++ b/shell/browser/api/electron_api_app.cc @@ -99,6 +99,7 @@ #include "content/browser/mac_helpers.h" #include "shell/browser/electron_child_process_host_flags.h" #include "shell/browser/ui/cocoa/electron_bundle_mover.h" +#include "shell/browser/webauthn/electron_authenticator_request_delegate.h" #include "shell/common/process_util.h" #endif @@ -1662,6 +1663,29 @@ bool App::IsInApplicationsFolder() { return ElectronBundleMover::IsCurrentAppInApplicationsFolder(); } +void App::ConfigureWebAuthn(gin_helper::ErrorThrower thrower, + gin::Arguments* args) { + gin_helper::Dictionary options; + if (!args->GetNext(&options)) { + thrower.ThrowTypeError("configureWebAuthn requires an options object"); + return; + } + + gin_helper::Dictionary touch_id; + if (options.Get("touchID", &touch_id)) { + std::string keychain_access_group; + if (!touch_id.Get("keychainAccessGroup", &keychain_access_group) || + keychain_access_group.empty()) { + thrower.ThrowTypeError( + "configureWebAuthn: 'touchID.keychainAccessGroup' must be a " + "non-empty string"); + return; + } + ElectronWebAuthenticationDelegate::SetTouchIdKeychainAccessGroup( + std::move(keychain_access_group)); + } +} + int DockBounce(gin::Arguments* args) { int request_id = -1; std::string type = "informational"; @@ -1891,6 +1915,7 @@ gin::ObjectTemplateBuilder App::GetObjectTemplateBuilder(v8::Isolate* isolate) { .SetMethod("moveToApplicationsFolder", &App::MoveToApplicationsFolder) .SetMethod("isInApplicationsFolder", &App::IsInApplicationsFolder) .SetMethod("setActivationPolicy", &App::SetActivationPolicy) + .SetMethod("configureWebAuthn", &App::ConfigureWebAuthn) #endif .SetMethod("setAboutPanelOptions", base::BindRepeating(&Browser::SetAboutPanelOptions, browser)) diff --git a/shell/browser/api/electron_api_app.h b/shell/browser/api/electron_api_app.h index ee205cdd52..e3f487934d 100644 --- a/shell/browser/api/electron_api_app.h +++ b/shell/browser/api/electron_api_app.h @@ -244,6 +244,8 @@ class App final : public gin::Wrappable, #if BUILDFLAG(IS_MAC) void SetActivationPolicy(gin_helper::ErrorThrower thrower, const std::string& policy); + void ConfigureWebAuthn(gin_helper::ErrorThrower thrower, + gin::Arguments* args); bool MoveToApplicationsFolder(gin_helper::ErrorThrower, gin::Arguments* args); bool IsInApplicationsFolder(); v8::Local GetDockAPI(v8::Isolate* isolate); diff --git a/shell/browser/electron_browser_client.cc b/shell/browser/electron_browser_client.cc index cd2e7ab30f..c49c10ebe1 100644 --- a/shell/browser/electron_browser_client.cc +++ b/shell/browser/electron_browser_client.cc @@ -115,6 +115,7 @@ #include "shell/browser/usb/electron_usb_delegate.h" #include "shell/browser/web_contents_permission_helper.h" #include "shell/browser/web_contents_preferences.h" +#include "shell/browser/webauthn/electron_authenticator_request_client_delegate.h" #include "shell/browser/webauthn/electron_authenticator_request_delegate.h" #include "shell/browser/window_list.h" #include "shell/common/api/api.mojom.h" @@ -1922,4 +1923,13 @@ ElectronBrowserClient::GetWebAuthenticationDelegate() { return web_authentication_delegate_.get(); } +#if !BUILDFLAG(IS_ANDROID) +std::unique_ptr +ElectronBrowserClient::GetWebAuthenticationRequestDelegate( + content::RenderFrameHost* render_frame_host) { + return std::make_unique( + render_frame_host); +} +#endif + } // namespace electron diff --git a/shell/browser/electron_browser_client.h b/shell/browser/electron_browser_client.h index adbae52f39..9ad3c999cb 100644 --- a/shell/browser/electron_browser_client.h +++ b/shell/browser/electron_browser_client.h @@ -127,6 +127,11 @@ class ElectronBrowserClient : public content::ContentBrowserClient, content::UsbDelegate* GetUsbDelegate() override; content::WebAuthenticationDelegate* GetWebAuthenticationDelegate() override; +#if !BUILDFLAG(IS_ANDROID) + std::unique_ptr + GetWebAuthenticationRequestDelegate( + content::RenderFrameHost* render_frame_host) override; +#endif #if BUILDFLAG(IS_MAC) std::string GetChildProcessSuffix(int child_flags) override; diff --git a/shell/browser/electron_browser_context.cc b/shell/browser/electron_browser_context.cc index b9800df6ff..4a425dc3af 100644 --- a/shell/browser/electron_browser_context.cc +++ b/shell/browser/electron_browser_context.cc @@ -500,6 +500,11 @@ void ElectronBrowserContext::InitPrefs() { // Unique uuid for global shortcuts. registry->RegisterStringPref(electron::kElectronGlobalShortcutsUuid, std::string()); + +#if BUILDFLAG(IS_MAC) + registry->RegisterStringPref(electron::kWebAuthnTouchIdMetadataSecretPrefName, + std::string()); +#endif } void ElectronBrowserContext::SetUserAgent(const std::string& user_agent) { diff --git a/shell/browser/webauthn/electron_authenticator_request_client_delegate.cc b/shell/browser/webauthn/electron_authenticator_request_client_delegate.cc new file mode 100644 index 0000000000..a1c9535a78 --- /dev/null +++ b/shell/browser/webauthn/electron_authenticator_request_client_delegate.cc @@ -0,0 +1,204 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/browser/webauthn/electron_authenticator_request_client_delegate.h" + +#include +#include + +#include "base/base64url.h" +#include "base/containers/span.h" +#include "base/functional/bind.h" +#include "content/public/browser/render_frame_host.h" +#include "content/public/browser/web_contents.h" +#include "device/fido/authenticator_get_assertion_response.h" +#include "device/fido/public/public_key_credential_descriptor.h" +#include "device/fido/public/public_key_credential_user_entity.h" +#include "gin/arguments.h" +#include "gin/data_object_builder.h" +#include "shell/browser/api/electron_api_session.h" +#include "shell/browser/javascript_environment.h" +#include "shell/common/gin_converters/callback_converter.h" +#include "shell/common/gin_converters/frame_converter.h" +#include "shell/common/gin_helper/event.h" +#include "shell/common/gin_helper/event_emitter_caller.h" + +namespace electron { + +namespace { + +// WebAuthn's PublicKeyCredential.id is canonically URL-safe base64 with no +// padding, so encode credential IDs and user handles the same way to keep the +// event payload string-comparable to values returned by navigator.credentials. +std::string Base64UrlEncodeNoPad(base::span input) { + std::string out; + base::Base64UrlEncode(input, base::Base64UrlEncodePolicy::OMIT_PADDING, &out); + return out; +} + +std::string CredentialIdFor( + const device::AuthenticatorGetAssertionResponse& response) { + if (response.credential) { + return Base64UrlEncodeNoPad(response.credential->id); + } + return {}; +} + +} // namespace + +ElectronAuthenticatorRequestClientDelegate:: + ElectronAuthenticatorRequestClientDelegate( + content::RenderFrameHost* render_frame_host) + : render_frame_host_id_(render_frame_host->GetGlobalId()) {} + +ElectronAuthenticatorRequestClientDelegate:: + ~ElectronAuthenticatorRequestClientDelegate() = default; + +void ElectronAuthenticatorRequestClientDelegate::SetRelyingPartyId( + const std::string& rp_id) { + relying_party_id_ = rp_id; +} + +void ElectronAuthenticatorRequestClientDelegate::StartObserving( + device::FidoRequestHandlerBase* request_handler) { + request_handler_observation_.Observe(request_handler); +} + +void ElectronAuthenticatorRequestClientDelegate::StopObserving( + device::FidoRequestHandlerBase* request_handler) { + request_handler_observation_.Reset(); +} + +void ElectronAuthenticatorRequestClientDelegate::RegisterActionCallbacks( + base::OnceClosure cancel_callback, + base::OnceClosure immediate_not_found_callback, + base::RepeatingClosure start_over_callback, + AccountPreselectedCallback account_preselected_callback, + PasswordSelectedCallback password_selected_callback, + device::FidoRequestHandlerBase::RequestCallback request_callback, + base::OnceClosure cancel_ui_timeout_callback, + base::RepeatingClosure bluetooth_adapter_power_on_callback, + base::RepeatingCallback< + void(device::FidoRequestHandlerBase::BlePermissionCallback)> + request_ble_permission_callback) { + cancel_callback_ = std::move(cancel_callback); +} + +void ElectronAuthenticatorRequestClientDelegate::SelectAccount( + std::vector responses, + base::OnceCallback + callback) { + DCHECK(!responses.empty()); + + content::RenderFrameHost* rfh = + content::RenderFrameHost::FromID(render_frame_host_id_); + content::WebContents* web_contents = + rfh ? content::WebContents::FromRenderFrameHost(rfh) : nullptr; + gin::WeakCell* session = + web_contents + ? api::Session::FromBrowserContext(web_contents->GetBrowserContext()) + : nullptr; + + pending_responses_ = std::move(responses); + select_account_callback_ = std::move(callback); + + if (!session || !session->Get()) { + CancelPendingAccountSelection(); + return; + } + + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope scope(isolate); + + v8::Local accounts = + v8::Array::New(isolate, static_cast(pending_responses_.size())); + for (size_t i = 0; i < pending_responses_.size(); ++i) { + const auto& response = pending_responses_[i]; + gin::DataObjectBuilder account(isolate); + account.Set("credentialId", CredentialIdFor(response)); + if (response.user_entity) { + account.Set("userHandle", Base64UrlEncodeNoPad(response.user_entity->id)); + if (response.user_entity->name) { + account.Set("name", *response.user_entity->name); + } + if (response.user_entity->display_name) { + account.Set("displayName", *response.user_entity->display_name); + } + } + accounts + ->CreateDataProperty(isolate->GetCurrentContext(), + static_cast(i), account.Build()) + .Check(); + } + + v8::Local details = gin::DataObjectBuilder(isolate) + .Set("relyingPartyId", relying_party_id_) + .Set("accounts", accounts) + .Set("frame", rfh) + .Build(); + + v8::Local session_wrapper; + if (!session->Get()->GetWrapper(isolate).ToLocal(&session_wrapper)) { + CancelPendingAccountSelection(); + return; + } + + v8::Local event_object = gin_helper::internal::Event::New(isolate) + ->GetWrapper(isolate) + .ToLocalChecked(); + + v8::Local emit_result = gin_helper::EmitEvent( + isolate, session_wrapper, "select-webauthn-account", event_object, + details, + base::BindRepeating( + &ElectronAuthenticatorRequestClientDelegate::OnAccountSelected, + weak_factory_.GetWeakPtr())); + + // EventEmitter.prototype.emit() returns true iff there was at least one + // listener. With no listener there is no way for the app to choose an + // account, so cancel rather than silently picking one. + bool had_listener = false; + if (!gin::ConvertFromV8(isolate, emit_result, &had_listener) || + !had_listener) { + CancelPendingAccountSelection(); + } +} + +void ElectronAuthenticatorRequestClientDelegate:: + CancelPendingAccountSelection() { + pending_responses_.clear(); + select_account_callback_.Reset(); + if (cancel_callback_) { + std::move(cancel_callback_).Run(); + } +} + +void ElectronAuthenticatorRequestClientDelegate::OnAccountSelected( + gin::Arguments* args) { + if (!select_account_callback_) { + return; + } + + std::string credential_id; + if (!args->GetNext(&credential_id) || credential_id.empty()) { + CancelPendingAccountSelection(); + return; + } + + for (auto& response : pending_responses_) { + if (CredentialIdFor(response) == credential_id) { + auto selected = std::move(response); + pending_responses_.clear(); + std::move(select_account_callback_).Run(std::move(selected)); + return; + } + } + + // Unknown credentialId: cancel the pending request rather than leaving it + // hanging. Matches the no-args branch above so the listener has a single, + // consistent failure mode whether it cancels deliberately or by mistake. + CancelPendingAccountSelection(); +} + +} // namespace electron diff --git a/shell/browser/webauthn/electron_authenticator_request_client_delegate.h b/shell/browser/webauthn/electron_authenticator_request_client_delegate.h new file mode 100644 index 0000000000..f5002961b4 --- /dev/null +++ b/shell/browser/webauthn/electron_authenticator_request_client_delegate.h @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_BROWSER_WEBAUTHN_ELECTRON_AUTHENTICATOR_REQUEST_CLIENT_DELEGATE_H_ +#define ELECTRON_SHELL_BROWSER_WEBAUTHN_ELECTRON_AUTHENTICATOR_REQUEST_CLIENT_DELEGATE_H_ + +#include + +#include "base/memory/weak_ptr.h" +#include "base/scoped_observation.h" +#include "content/public/browser/authenticator_request_client_delegate.h" +#include "content/public/browser/global_routing_id.h" + +namespace content { +class RenderFrameHost; +} + +namespace gin { +class Arguments; +} + +namespace electron { + +class ElectronAuthenticatorRequestClientDelegate + : public content::AuthenticatorRequestClientDelegate { + public: + explicit ElectronAuthenticatorRequestClientDelegate( + content::RenderFrameHost* render_frame_host); + ~ElectronAuthenticatorRequestClientDelegate() override; + + // disable copy + ElectronAuthenticatorRequestClientDelegate( + const ElectronAuthenticatorRequestClientDelegate&) = delete; + ElectronAuthenticatorRequestClientDelegate& operator=( + const ElectronAuthenticatorRequestClientDelegate&) = delete; + + // content::AuthenticatorRequestClientDelegate: + void SetRelyingPartyId(const std::string& rp_id) override; + void StartObserving(device::FidoRequestHandlerBase* request_handler) override; + void StopObserving(device::FidoRequestHandlerBase* request_handler) override; + void RegisterActionCallbacks( + base::OnceClosure cancel_callback, + base::OnceClosure immediate_not_found_callback, + base::RepeatingClosure start_over_callback, + AccountPreselectedCallback account_preselected_callback, + PasswordSelectedCallback password_selected_callback, + device::FidoRequestHandlerBase::RequestCallback request_callback, + base::OnceClosure cancel_ui_timeout_callback, + base::RepeatingClosure bluetooth_adapter_power_on_callback, + base::RepeatingCallback< + void(device::FidoRequestHandlerBase::BlePermissionCallback)> + request_ble_permission_callback) override; + void SelectAccount( + std::vector responses, + base::OnceCallback + callback) override; + + private: + void OnAccountSelected(gin::Arguments* args); + void CancelPendingAccountSelection(); + + const content::GlobalRenderFrameHostId render_frame_host_id_; + std::string relying_party_id_; + base::OnceClosure cancel_callback_; + + base::ScopedObservation + request_handler_observation_{this}; + + std::vector pending_responses_; + base::OnceCallback + select_account_callback_; + + base::WeakPtrFactory + weak_factory_{this}; +}; + +} // namespace electron + +#endif // ELECTRON_SHELL_BROWSER_WEBAUTHN_ELECTRON_AUTHENTICATOR_REQUEST_CLIENT_DELEGATE_H_ diff --git a/shell/browser/webauthn/electron_authenticator_request_delegate.cc b/shell/browser/webauthn/electron_authenticator_request_delegate.cc index 598cfd98da..2bfff46811 100644 --- a/shell/browser/webauthn/electron_authenticator_request_delegate.cc +++ b/shell/browser/webauthn/electron_authenticator_request_delegate.cc @@ -4,6 +4,17 @@ #include "shell/browser/webauthn/electron_authenticator_request_delegate.h" +#if BUILDFLAG(IS_MAC) +#include "base/base64.h" +#include "base/containers/span.h" +#include "base/no_destructor.h" +#include "components/prefs/pref_service.h" +#include "content/public/browser/browser_thread.h" +#include "device/fido/mac/credential_metadata.h" +#include "shell/browser/electron_browser_context.h" +#include "shell/common/electron_constants.h" +#endif + namespace electron { ElectronWebAuthenticationDelegate::~ElectronWebAuthenticationDelegate() = @@ -14,4 +25,48 @@ bool ElectronWebAuthenticationDelegate::SupportsResidentKeys( return true; } +#if BUILDFLAG(IS_MAC) +// static +std::string& +ElectronWebAuthenticationDelegate::touch_id_keychain_access_group() { + static base::NoDestructor value; + return *value; +} + +// static +void ElectronWebAuthenticationDelegate::SetTouchIdKeychainAccessGroup( + std::string access_group) { + DCHECK_CURRENTLY_ON(content::BrowserThread::UI); + touch_id_keychain_access_group() = std::move(access_group); +} + +std::optional +ElectronWebAuthenticationDelegate::GetTouchIdAuthenticatorConfig( + content::BrowserContext* browser_context) { + // The metadata-secret pref is read-then-written; serialize on the UI thread + // to avoid two callers each generating a fresh secret and clobbering each + // other's credentials. + DCHECK_CURRENTLY_ON(content::BrowserThread::UI); + const std::string& access_group = touch_id_keychain_access_group(); + if (access_group.empty()) { + return std::nullopt; + } + + PrefService* prefs = + static_cast(browser_context)->prefs(); + std::string metadata_secret = + prefs->GetString(kWebAuthnTouchIdMetadataSecretPrefName); + if (metadata_secret.empty() || + !base::Base64Decode(metadata_secret, &metadata_secret)) { + metadata_secret = device::fido::mac::GenerateCredentialMetadataSecret(); + prefs->SetString(kWebAuthnTouchIdMetadataSecretPrefName, + base::Base64Encode(base::as_byte_span(metadata_secret))); + } + + return TouchIdAuthenticatorConfig{ + .keychain_access_group = access_group, + .metadata_secret = std::move(metadata_secret)}; +} +#endif // BUILDFLAG(IS_MAC) + } // namespace electron diff --git a/shell/browser/webauthn/electron_authenticator_request_delegate.h b/shell/browser/webauthn/electron_authenticator_request_delegate.h index 3eb5b422d8..cf9586370c 100644 --- a/shell/browser/webauthn/electron_authenticator_request_delegate.h +++ b/shell/browser/webauthn/electron_authenticator_request_delegate.h @@ -5,6 +5,10 @@ #ifndef ELECTRON_SHELL_BROWSER_WEBAUTHN_ELECTRON_AUTHENTICATOR_REQUEST_DELEGATE_H_ #define ELECTRON_SHELL_BROWSER_WEBAUTHN_ELECTRON_AUTHENTICATOR_REQUEST_DELEGATE_H_ +#include +#include + +#include "build/build_config.h" #include "content/public/browser/web_authentication_delegate.h" namespace electron { @@ -15,9 +19,26 @@ class ElectronWebAuthenticationDelegate public: ~ElectronWebAuthenticationDelegate() override; +#if BUILDFLAG(IS_MAC) + // Configures the keychain-access-group that the Touch ID platform + // authenticator stores credentials under. The app's entitlements must include + // this value in the `keychain-access-groups` array. Called from + // `app.configureWebAuthn()`. + static void SetTouchIdKeychainAccessGroup(std::string access_group); +#endif + // content::WebAuthenticationDelegate bool SupportsResidentKeys( content::RenderFrameHost* render_frame_host) override; +#if BUILDFLAG(IS_MAC) + std::optional GetTouchIdAuthenticatorConfig( + content::BrowserContext* browser_context) override; +#endif + + private: +#if BUILDFLAG(IS_MAC) + static std::string& touch_id_keychain_access_group(); +#endif }; } // namespace electron diff --git a/shell/common/electron_constants.h b/shell/common/electron_constants.h index 7456addb37..b29351ba6d 100644 --- a/shell/common/electron_constants.h +++ b/shell/common/electron_constants.h @@ -30,6 +30,11 @@ inline constexpr base::cstring_view kRunAsNode = "ELECTRON_RUN_AS_NODE"; inline constexpr char kElectronGlobalShortcutsUuid[] = "electron.global_shortcuts.uuid"; +// Per-profile secret used to encrypt Touch ID WebAuthn credential metadata +// stored in the macOS keychain. +inline constexpr char kWebAuthnTouchIdMetadataSecretPrefName[] = + "electron.webauthn.touchid.metadata_secret"; + #if BUILDFLAG(ENABLE_PDF_VIEWER) inline constexpr std::string_view kPDFExtensionPluginName = "Chromium PDF Viewer"; diff --git a/spec/api-web-authn-spec.ts b/spec/api-web-authn-spec.ts new file mode 100644 index 0000000000..b250646b14 --- /dev/null +++ b/spec/api-web-authn-spec.ts @@ -0,0 +1,237 @@ +import { app, BrowserWindow, session } from 'electron/main'; + +import { expect } from 'chai'; + +import * as http from 'node:http'; +import { AddressInfo } from 'node:net'; + +import { ifdescribe } from './lib/spec-helpers'; +import { closeAllWindows } from './lib/window-helpers'; + +const configureWebAuthn = (app as any).configureWebAuthn?.bind(app) as (options?: unknown) => void; + +ifdescribe(process.platform === 'darwin')('app.configureWebAuthn', () => { + it('throws when called without an options object', () => { + expect(() => configureWebAuthn()).to.throw(/configureWebAuthn requires an options object/); + }); + + it('throws when touchID.keychainAccessGroup is missing', () => { + expect(() => configureWebAuthn({ touchID: {} })).to.throw(/keychainAccessGroup/); + }); + + it('throws when touchID.keychainAccessGroup is empty', () => { + expect(() => configureWebAuthn({ touchID: { keychainAccessGroup: '' } })).to.throw(/keychainAccessGroup/); + }); + + it('accepts a valid touchID configuration', () => { + expect(() => + configureWebAuthn({ + touchID: { keychainAccessGroup: 'TESTTEAMID.org.electron.spec.webauthn' } + }) + ).to.not.throw(); + }); +}); + +describe("session 'select-webauthn-account' event", () => { + let server: http.Server; + let serverUrl: string; + let w: BrowserWindow; + let authenticatorId: string; + + before(async () => { + server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end('webauthn'); + }); + await new Promise((resolve) => server.listen(0, 'localhost', resolve)); + const { port } = server.address() as AddressInfo; + serverUrl = `http://localhost:${port}/`; + }); + + after(() => { + server.close(); + }); + + beforeEach(async () => { + w = new BrowserWindow({ show: false }); + await w.loadURL(serverUrl); + w.webContents.debugger.attach(); + await w.webContents.debugger.sendCommand('WebAuthn.enable'); + const result = await w.webContents.debugger.sendCommand('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true + } + }); + authenticatorId = result.authenticatorId; + }); + + afterEach(async () => { + session.defaultSession.removeAllListeners('select-webauthn-account'); + try { + w.webContents.debugger.detach(); + } catch {} + await closeAllWindows(); + }); + + async function addCredential (opts: { id: string; userHandle: string; name: string; displayName: string }) { + const privateKey = await w.webContents.executeJavaScript(` + (async () => { + const k = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']); + const pkcs8 = await crypto.subtle.exportKey('pkcs8', k.privateKey); + return btoa(String.fromCharCode(...new Uint8Array(pkcs8))); + })() + `); + await w.webContents.debugger.sendCommand('WebAuthn.addCredential', { + authenticatorId, + credential: { + credentialId: Buffer.from(opts.id).toString('base64'), + isResidentCredential: true, + rpId: 'localhost', + privateKey, + userHandle: Buffer.from(opts.userHandle).toString('base64'), + userName: opts.name, + userDisplayName: opts.displayName, + signCount: 0 + } + }); + } + + function getAssertion () { + return w.webContents.executeJavaScript(` + navigator.credentials.get({ + publicKey: { + challenge: new Uint8Array(32), + rpId: 'localhost', + userVerification: 'required' + } + }).then( + c => { + const userHandle = c.response.userHandle + ? new Uint8Array(c.response.userHandle) + .toBase64({ alphabet: 'base64url', omitPadding: true }) + : null; + return { ok: true, id: c.id, userHandle }; + }, + e => ({ ok: false, name: e.name, message: e.message }) + ) + `); + } + + // Regression test: MakeCredentialRequestHandler::SpecializeRequestForAuthenticator + // dereferences observer() for ResidentKeyRequirement::kPreferred, which crashed + // when the request delegate did not register itself as the handler's observer. + it('does not crash on navigator.credentials.create() with residentKey "preferred"', async () => { + const result = await w.webContents.executeJavaScript(` + navigator.credentials.create({ + publicKey: { + rp: { id: 'localhost', name: 'Electron Spec' }, + user: { + id: new TextEncoder().encode('user-1'), + name: 'alice@example.com', + displayName: 'Alice' + }, + challenge: new Uint8Array(32), + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred' + } + } + }).then( + c => ({ ok: true, id: c.id }), + e => ({ ok: false, name: e.name, message: e.message }) + ) + `); + expect(result.ok).to.be.true(); + expect(result.id).to.be.a('string').and.not.be.empty(); + }); + + // Pick byte sequences that exercise the URL-safe base64 alphabet — '?' and + // '>' both produce '+' / '/' / padding in standard base64, so a regression + // back to base::Base64Encode would surface here as a mismatch between the + // event's credentialId/userHandle and the values the renderer sees. + const BOB_ID = 'cred-bob??'; + const BOB_USER_HANDLE = 'uh-bob>>'; + + const toBase64Url = (s: string) => Buffer.from(s).toString('base64url'); + + it('fires with discoverable credentials and resolves get() with the chosen one', async () => { + await addCredential({ id: 'cred-alice', userHandle: 'uh-alice', name: 'alice@example.com', displayName: 'Alice' }); + await addCredential({ id: BOB_ID, userHandle: BOB_USER_HANDLE, name: 'bob@example.com', displayName: 'Bob' }); + + let received: any; + (w.webContents.session as NodeJS.EventEmitter).on('select-webauthn-account', (event, details, callback) => { + received = details; + const bob = details.accounts.find((a: any) => a.name === 'bob@example.com'); + callback(bob.credentialId); + }); + + const result = await getAssertion(); + + expect(received).to.exist(); + expect(received.relyingPartyId).to.equal('localhost'); + expect(received.accounts).to.have.lengthOf(2); + const names = received.accounts.map((a: any) => a.name).sort(); + expect(names).to.deep.equal(['alice@example.com', 'bob@example.com']); + const bob = received.accounts.find((a: any) => a.name === 'bob@example.com'); + expect(bob.displayName).to.equal('Bob'); + expect(bob.credentialId).to.be.a('string').and.not.be.empty(); + + // Both credentialId and userHandle must be URL-safe base64 (no '+', '/' + // or padding) so the values are byte-for-byte comparable to what the + // WebAuthn JS API exposes on PublicKeyCredential. + expect(bob.credentialId).to.equal(toBase64Url(BOB_ID)); + expect(bob.userHandle).to.equal(toBase64Url(BOB_USER_HANDLE)); + expect(bob.credentialId).to.not.match(/[+/=]/); + expect(bob.userHandle).to.not.match(/[+/=]/); + + // The strong invariant: the credentialId surfaced via the main-process + // event is the same string the renderer sees as PublicKeyCredential.id. + expect(result.ok).to.be.true(); + expect(result.id).to.equal(bob.credentialId); + expect(result.userHandle).to.equal(bob.userHandle); + }); + + it('cancels the request when the callback is invoked with an unknown credentialId', async () => { + await addCredential({ id: 'cred-alice', userHandle: 'uh-alice', name: 'alice@example.com', displayName: 'Alice' }); + await addCredential({ id: 'cred-bob', userHandle: 'uh-bob', name: 'bob@example.com', displayName: 'Bob' }); + + (w.webContents.session as NodeJS.EventEmitter).on('select-webauthn-account', (event, details, callback) => { + callback('not-a-real-credential-id'); + }); + + const result = await getAssertion(); + expect(result.ok).to.be.false(); + expect(result.name).to.equal('NotAllowedError'); + }); + + it('cancels the request when the callback is invoked with no credential', async () => { + await addCredential({ id: 'cred-alice', userHandle: 'uh-alice', name: 'alice@example.com', displayName: 'Alice' }); + await addCredential({ id: 'cred-bob', userHandle: 'uh-bob', name: 'bob@example.com', displayName: 'Bob' }); + + (w.webContents.session as NodeJS.EventEmitter).on('select-webauthn-account', (event, details, callback) => { + callback(); + }); + + const result = await getAssertion(); + expect(result.ok).to.be.false(); + expect(result.name).to.equal('NotAllowedError'); + }); + + it('cancels the request when no listener is registered', async () => { + await addCredential({ id: 'cred-alice', userHandle: 'uh-alice', name: 'alice@example.com', displayName: 'Alice' }); + await addCredential({ id: 'cred-bob', userHandle: 'uh-bob', name: 'bob@example.com', displayName: 'Bob' }); + + expect(w.webContents.session.listenerCount('select-webauthn-account')).to.equal(0); + + const result = await getAssertion(); + expect(result.ok).to.be.false(); + expect(result.name).to.equal('NotAllowedError'); + }); +});