blob: ccd0dd653cb21fbdd68adf89b3d30df83a822039 [file]
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chrome/browser/ui/webauthn/authenticator_request_window.h"
#include <memory>
#include <string>
#include <string_view>
#include <utility>
#include "base/command_line.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/memory/raw_ptr.h"
#include "base/memory/weak_ptr.h"
#include "base/notreached.h"
#include "base/scoped_observation.h"
#include "base/sequence_checker_impl.h"
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "base/task/single_thread_task_runner.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/signin/identity_manager_factory.h"
#include "chrome/browser/trusted_vault/trusted_vault_encryption_keys_tab_helper.h"
#include "chrome/browser/ui/browser_window.h"
#include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
#include "chrome/browser/ui/browser_window/public/create_browser_window.h"
#include "chrome/browser/ui/browser_window/public/global_browser_collection.h"
#include "chrome/browser/ui/tabs/tab_enums.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
#include "chrome/browser/ui/webauthn/user_actions.h"
#include "chrome/browser/webauthn/authenticator_request_dialog_model.h"
#include "chrome/browser/webauthn/gpm_enclave_controller.h"
#include "chrome/browser/webauthn/webauthn_switches.h"
#include "components/signin/public/identity_manager/identity_manager.h"
#include "components/trusted_vault/trusted_vault_histograms.h"
#include "content/public/browser/navigation_controller.h"
#include "content/public/browser/navigation_handle.h"
#include "content/public/browser/web_contents_observer.h"
#include "device/fido/enclave/metrics.h"
#include "device/fido/public/features.h"
#include "google_apis/gaia/gaia_urls.h"
#include "net/base/url_util.h"
#include "net/http/http_response_headers.h"
#include "net/http/http_status_code.h"
#include "ui/base/page_transition_types.h"
#include "ui/gfx/geometry/point.h"
#include "ui/gfx/geometry/rect.h"
#include "url/origin.h"
namespace {
constexpr std::string_view kMagicArchUrl = "https://passwords.google.com";
constexpr std::string_view kGpmPasskeyPinResetPath = "/encryption/pin/reset";
constexpr std::string_view kGpmPasskeyResetSuccessPath =
"/embedded/passkeys/reset/done";
constexpr std::string_view kGpmPasskeyResetFailPath =
"/embedded/passkeys/reset/error";
GURL GetGpmMagicArchUrl() {
std::string command_line_url =
base::CommandLine::ForCurrentProcess()->GetSwitchValueASCII(
webauthn::switches::kGpmMagicArchUrlSwitch);
if (command_line_url.empty()) {
// Command line switch is not specified or is not a valid ASCII string.
return GURL(kMagicArchUrl);
}
return GURL(command_line_url);
}
// This WebContents observer watches the WebView that shows a GAIA
// reauthentication page. When that page navigates to a URL that includes the
// resulting RAPT token, it invokes a callback with that token.
class ReauthWebContentsObserver : public content::WebContentsObserver {
public:
ReauthWebContentsObserver(content::WebContents* web_contents,
base::OnceCallback<void(std::string)> callback)
: content::WebContentsObserver(web_contents),
callback_(std::move(callback)) {}
void DidFinishNavigation(
content::NavigationHandle* navigation_handle) override {
const GURL& url = navigation_handle->GetURL();
if (!callback_ || !navigation_handle->IsInPrimaryMainFrame() ||
!url::IsSameOriginWith(GetGpmMagicArchUrl(), url) ||
!navigation_handle->GetResponseHeaders() ||
!IsValidHttpStatus(
navigation_handle->GetResponseHeaders()->response_code())) {
return;
}
std::string rapt;
if (!net::GetValueForKeyInQuery(url, "rapt", &rapt)) {
return;
}
// Post a task to avoid attempting to close the web contents during
// execution of an observer method, which can trigger a crash.
base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback_), std::move(rapt)));
}
private:
static bool IsValidHttpStatus(int status) {
return status == net::HTTP_OK || status == net::HTTP_NO_CONTENT;
}
base::OnceCallback<void(std::string)> callback_;
};
// The user may be prompted to reset their passkeys if the MagicArch PIN
// challenge fails. MagicArch will then navigate to either
// `kGpmPasskeyResetSuccessUrl` or `kGpmPasskeyResetFailUrl` after completing a
// reset. The pages have a message on and a button. If the user clicks the
// button, a ref is appended to the URL. This observer will observe which page
// the user is on and call the `callback_`.
class PasskeyResetWebContentsObserver : public content::WebContentsObserver {
public:
enum class Status {
// Passkeys reset flow not started. The user is still in the PIN screen.
kNotStarted,
// Passkeys reset flow not completed.
kStarted,
// Passkeys reset flow succeeded. The user has not clicked the button in the
// page yet.
kSuccess,
// Passkeys reset flow failed. The user has not clicked the button in the
// page yet.
kFail,
};
PasskeyResetWebContentsObserver(content::WebContents* web_contents,
base::OnceCallback<void(bool)> callback)
: content::WebContentsObserver(web_contents),
callback_(std::move(callback)) {}
void DidFinishNavigation(
content::NavigationHandle* navigation_handle) override {
const GURL& url = navigation_handle->GetURL();
if (!callback_ || !navigation_handle->IsInPrimaryMainFrame() ||
!url::IsSameOriginWith(url, GetGpmMagicArchUrl()) || !url.has_path()) {
return;
}
status_ = Status::kStarted;
if (url.GetPath() == kGpmPasskeyResetSuccessPath) {
status_ = Status::kSuccess;
} else if (url.GetPath() == kGpmPasskeyResetFailPath) {
status_ = Status::kFail;
}
MaybeRunCallback(url.has_ref() ? url.GetRef() : "");
}
Status status() const { return status_; }
private:
void MaybeRunCallback(const std::string& ref) {
if (status_ == Status::kStarted || ref.empty()) {
return;
}
// Reset `status_` to avoid automatically closing the web contents resulting
// in calling `OnPasskeysReset` again.
Status status = status_;
status_ = Status::kNotStarted;
if (status == Status::kSuccess && ref == "success") {
// Post a task to avoid attempting to close the web contents during
// execution of an observer method, which can trigger a crash.
base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback_), true));
}
if (status == Status::kFail && ref == "fail") {
// Post a task to avoid attempting to close the web contents during
// execution of an observer method, which can trigger a crash.
base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback_), false));
}
}
Status status_ = Status::kNotStarted;
base::OnceCallback<void(bool)> callback_;
};
// Shows a pop-up window containing some WebAuthn-related UI. This object
// owns itself.
class AuthenticatorRequestWindow
: public content::WebContentsObserver,
public AuthenticatorRequestDialogModel::Observer {
public:
using PasskeyResetStatus = PasskeyResetWebContentsObserver::Status;
explicit AuthenticatorRequestWindow(content::WebContents* caller_web_contents,
AuthenticatorRequestDialogModel* model)
: step_(model->step()), model_(model) {
model_observation_.Observe(model_);
// The original profile is used so that cookies are available. If this is an
// Incognito session then a warning has already been shown to the user.
Profile* const profile =
Profile::FromBrowserContext(caller_web_contents->GetBrowserContext())
->GetOriginalProfile();
// If the profile is shutting down, don't attempt to create a pop-up.
if (GetBrowserWindowCreationStatusForProfile(*profile) !=
BrowserWindowInterface::CreationStatus::kOk) {
return;
}
// The pop-up window will be centered on top of the Browser doing the
// WebAuthn operation.
BrowserWindowInterface* const caller_browser =
GlobalBrowserCollection::GetInstance()->FindBrowserWithTab(
caller_web_contents);
const gfx::Rect caller_bounds = caller_browser->GetWindow()->GetBounds();
const gfx::Point caller_center = caller_bounds.CenterPoint();
BrowserWindowCreateParams browser_params(BrowserWindowInterface::TYPE_POPUP,
profile,
/*from_user_gesture=*/true);
browser_params.omit_from_session_restore = true;
browser_params.should_trigger_session_restore = false;
// This is empirically a good size for the MagicArch UI. (Note that the UI
// is much larger when the user needs to enter an unlock pattern, so don't
// size this purely based on PIN entry.)
constexpr int kWidth = 900;
constexpr int kHeight = 750;
browser_params.initial_bounds =
gfx::Rect(caller_center.x() - kWidth / 2,
caller_center.y() - kHeight / 2, kWidth, kHeight);
browser_params.initial_origin_specified =
BrowserWindowCreateParams::ValueSpecified::kSpecified;
BrowserWindowInterface* browser =
CreateBrowserWindow(std::move(browser_params));
content::WebContents::CreateParams webcontents_params(profile);
std::unique_ptr<content::WebContents> web_contents =
content::WebContents::Create(webcontents_params);
WebContentsObserver::Observe(web_contents.get());
signin::IdentityManager* identity_manager =
IdentityManagerFactory::GetForProfile(profile);
// Default to the first account if the account is not present in the cookie
// jar. This can happen in tests, or if account state changed right before
// this code.
size_t account_index =
identity_manager->GetSessionIndexForPrimaryAccount().value_or(0u);
GURL url;
switch (step_) {
case AuthenticatorRequestDialogModel::Step::kGPMRecoverSecurityDomain:
if (base::FeatureList::IsEnabled(
device::kWebAuthnGpmPasskeyEmbeddedRecoveryUrl)) {
url =
GaiaUrls::GetInstance()
->SigninChromePasskeyUnlockDesktopEmbeddedUrl(account_index);
trusted_vault::RecordTrustedVaultRecoveryFlowTriggeredEndpoint(
trusted_vault::TrustedVaultRecoveryFlowEndpoint::
kDesktopEmbedded);
} else {
url = GaiaUrls::GetInstance()->SigninChromePasskeyUnlockUrl(
account_index);
trusted_vault::RecordTrustedVaultRecoveryFlowTriggeredEndpoint(
trusted_vault::TrustedVaultRecoveryFlowEndpoint::kDesktop);
}
device::enclave::RecordEvent(device::enclave::Event::kRecoveryShown);
webauthn::user_actions::RecordRecoveryShown(model_->request_type);
passkey_reset_observer_ =
std::make_unique<PasskeyResetWebContentsObserver>(
web_contents.get(),
base::BindOnce(&AuthenticatorRequestWindow::OnPasskeysReset,
weak_ptr_factory_.GetWeakPtr()));
break;
case AuthenticatorRequestDialogModel::Step::kGPMReauthForPinReset:
url = GetGpmMagicArchUrl().Resolve(kGpmPasskeyPinResetPath);
if (base::FeatureList::IsEnabled(
device::kWebAuthnGpmPinResetUsesAccountIndex)) {
url = net::AppendQueryParameter(url, "authuser",
base::NumberToString(account_index));
}
reauth_observer_ = std::make_unique<ReauthWebContentsObserver>(
web_contents.get(),
base::BindOnce(&AuthenticatorRequestWindow::OnHaveToken,
weak_ptr_factory_.GetWeakPtr()));
break;
default:
NOTREACHED();
}
content::NavigationController::LoadURLParams load_params(url);
base::WeakPtr<content::NavigationHandle> navigation_handle =
web_contents->GetController().LoadURLWithParams(load_params);
web_contents_weak_ptr_ = web_contents->GetWeakPtr();
if (navigation_handle &&
step_ ==
AuthenticatorRequestDialogModel::Step::kGPMRecoverSecurityDomain) {
TrustedVaultEncryptionKeysTabHelper* encryption_keys_tab_helper =
TrustedVaultEncryptionKeysTabHelper::FromWebContents(
navigation_handle->GetWebContents());
if (encryption_keys_tab_helper) {
encryption_keys_tab_helper->SetUserActionTrigger(
trusted_vault::TrustedVaultUserActionTriggerForUMA::
kPasskeyBootstrappingFlow);
}
}
browser->GetTabStripModel()->AddWebContents(
std::move(web_contents), /*index=*/0,
ui::PageTransition::PAGE_TRANSITION_AUTO_TOPLEVEL,
AddTabTypes::ADD_ACTIVE);
browser->GetWindow()->Show();
}
~AuthenticatorRequestWindow() override = default;
protected:
void WebContentsDestroyed() override {
WebContentsObserver::Observe(nullptr);
if (!model_) {
return;
}
// If the passkey reset is complete and the user has not clicked the button
// in the page, we still wish to react to the reset.
if (passkey_reset_observer_ &&
(passkey_reset_observer_->status() == PasskeyResetStatus::kSuccess ||
passkey_reset_observer_->status() == PasskeyResetStatus::kFail)) {
OnPasskeysReset(passkey_reset_observer_->status() ==
PasskeyResetStatus::kSuccess);
return;
}
if (model_->step() == step_) {
webauthn::user_actions::RecordRecoveryCancelled();
model_->OnGPMRecoverSecurityDomainClosed();
}
}
// AuthenticatorRequestDialogModel::Observer:
void OnModelDestroyed(AuthenticatorRequestDialogModel* model) override {
CloseWindowAndDeleteSelf();
}
void OnStepTransition() override {
if (model_->step() != step_) {
// No UI step involving a window leads to another step involving another
// window. So any transition of the model must be to a step that doesn't
// have one.
CloseWindowAndDeleteSelf();
}
}
private:
void CloseWindowAndDeleteSelf() {
if (web_contents_weak_ptr_) {
web_contents_weak_ptr_->Close();
}
delete this;
}
void OnHaveToken(std::string rapt) {
if (model_) {
model_->OnGPMReauthComplete(std::move(rapt));
}
}
void OnPasskeysReset(bool success) {
if (model_) {
model_->OnGPMPasskeysReset(success);
}
}
const AuthenticatorRequestDialogModel::Step step_;
raw_ptr<AuthenticatorRequestDialogModel> model_;
std::unique_ptr<ReauthWebContentsObserver> reauth_observer_;
std::unique_ptr<PasskeyResetWebContentsObserver> passkey_reset_observer_;
base::WeakPtr<content::WebContents> web_contents_weak_ptr_;
base::ScopedObservation<AuthenticatorRequestDialogModel,
AuthenticatorRequestWindow>
model_observation_{this};
base::WeakPtrFactory<AuthenticatorRequestWindow> weak_ptr_factory_{this};
};
} // namespace
void ShowAuthenticatorRequestWindow(content::WebContents* web_contents,
AuthenticatorRequestDialogModel* model) {
// This object owns itself.
new AuthenticatorRequestWindow(web_contents, model);
}
bool IsAuthenticatorRequestWindowUrl(const GURL& url) {
std::string kdi;
return net::GetValueForKeyInQuery(url, "kdi", &kdi) &&
kdi == GaiaUrls::GetInstance()
->signin_chrome_passkey_unlock_kdi_parameter();
}