1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

PM-19095: Wire passkey autofill to UI (#13051)

* Passkey stuff

Co-authored-by: Anders Åberg <github@andersaberg.com>

* Ugly hacks

* Work On Modal State Management

* Applying modalStyles

* modal

* Improved hide/show

* fixed promise

* File name

* fix prettier

* Protecting against null API's and undefined data

* Only show fake popup to devs

* cleanup mock code

* rename minmimal-app to modal-app

* Added comment

* Added comment

* removed old comment

* Avoided changing minimum size

* Add small comment

* Rename component

* adress feedback

* Fixed uppercase file

* Fixed build

* Added codeowners

* added void

* commentary

* feat: reset setting on app start

* Moved reset to be in main / process launch

* Add comment to create window

* Added a little bit of styling

* Use Messaging service to loadUrl

* Enable passkeysautofill

* Add logging

* halfbaked

* Integration working

* And now it works without extra delay

* Clean up

* add note about messaging

* lb

* removed console.logs

* Cleanup and adress review feedback

* This hides the swift UI

* pick credential, draft

* Remove logger

* a whole lot of wiring

* not working

* Improved wiring

* Cancel after 90s

* Introduced observable

* Launching bitwarden if its not running

* Passing position from native to electron

* Rename inModalMode to modalMode

* remove tap

* revert spaces

* added back isDev

* cleaned up a bit

* Cleanup swift file

* tweaked logging

* clean up

* Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Update apps/desktop/src/platform/main/autofill/native-autofill.main.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Update apps/desktop/src/platform/services/desktop-settings.service.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* adress position feedback

* Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Removed extra logging

* Adjusted error logging

* Use .error to log errors

* remove dead code

* Update desktop-autofill.service.ts

* use parseCredentialId instead of guidToRawFormat

* Update apps/desktop/src/autofill/services/desktop-autofill.service.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Change windowXy to a Record instead of [number,number]

* Update apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Remove unsued dep and comment

* changed timeout to be spec recommended maxium, 10 minutes, for now.

* Correctly assume UP

* Removed extra cancelRequest in deinint

* Add timeout and UV to confirmChoseCipher

UV is performed by UI, not the service

* Improved docs regarding undefined cipherId

* cleanup: UP is no longer undefined

* Run completeError if ipc messages conversion failed

* don't throw, instead return undefined

* Disabled passkey provider

* Throw error if no activeUserId was found

* removed comment

* Fixed lint

* removed unsued service

* reset entitlement formatting

* Update entitlements.mas.plist

---------

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Co-authored-by: Colton Hurst <colton@coltonhurst.com>
Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
This commit is contained in:
Anders Åberg
2025-03-24 12:50:11 +01:00
committed by GitHub
parent a6e785d63c
commit 8e455007c0
22 changed files with 826 additions and 208 deletions

View File

@@ -2,11 +2,22 @@ use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{BitwardenError, Callback, UserVerification};
use crate::{BitwardenError, Callback, Position, UserVerification};
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionRequest {
rp_id: String,
client_data_hash: Vec<u8>,
user_verification: UserVerification,
allowed_credentials: Vec<Vec<u8>>,
window_xy: Position,
//extension_input: Vec<u8>, TODO: Implement support for extensions
}
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
rp_id: String,
credential_id: Vec<u8>,
user_name: String,
@@ -14,6 +25,7 @@ pub struct PasskeyAssertionRequest {
record_identifier: Option<String>,
client_data_hash: Vec<u8>,
user_verification: UserVerification,
window_xy: Position,
}
#[derive(uniffi::Record, Serialize, Deserialize)]

View File

@@ -15,7 +15,10 @@ uniffi::setup_scaffolding!();
mod assertion;
mod registration;
use assertion::{PasskeyAssertionRequest, PreparePasskeyAssertionCallback};
use assertion::{
PasskeyAssertionRequest, PasskeyAssertionWithoutUserInterfaceRequest,
PreparePasskeyAssertionCallback,
};
use registration::{PasskeyRegistrationRequest, PreparePasskeyRegistrationCallback};
#[derive(uniffi::Enum, Debug, Serialize, Deserialize)]
@@ -26,6 +29,13 @@ pub enum UserVerification {
Discouraged,
}
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Position {
pub x: i32,
pub y: i32,
}
#[derive(Debug, uniffi::Error, Serialize, Deserialize)]
pub enum BitwardenError {
Internal(String),
@@ -141,6 +151,14 @@ impl MacOSProviderClient {
) {
self.send_message(request, Box::new(callback));
}
pub fn prepare_passkey_assertion_without_user_interface(
&self,
request: PasskeyAssertionWithoutUserInterfaceRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>,
) {
self.send_message(request, Box::new(callback));
}
}
#[derive(Serialize, Deserialize)]

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{BitwardenError, Callback, UserVerification};
use crate::{BitwardenError, Callback, Position, UserVerification};
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -13,6 +13,7 @@ pub struct PasskeyRegistrationRequest {
client_data_hash: Vec<u8>,
user_verification: UserVerification,
supported_algorithms: Vec<i32>,
window_xy: Position,
}
#[derive(uniffi::Record, Serialize, Deserialize)]

View File

@@ -118,6 +118,10 @@ export declare namespace autofill {
Required = 'required',
Discouraged = 'discouraged'
}
export interface Position {
x: number
y: number
}
export interface PasskeyRegistrationRequest {
rpId: string
userName: string
@@ -125,6 +129,7 @@ export declare namespace autofill {
clientDataHash: Array<number>
userVerification: UserVerification
supportedAlgorithms: Array<number>
windowXy: Position
}
export interface PasskeyRegistrationResponse {
rpId: string
@@ -133,6 +138,13 @@ export declare namespace autofill {
attestationObject: Array<number>
}
export interface PasskeyAssertionRequest {
rpId: string
clientDataHash: Array<number>
userVerification: UserVerification
allowedCredentials: Array<Array<number>>
windowXy: Position
}
export interface PasskeyAssertionWithoutUserInterfaceRequest {
rpId: string
credentialId: Array<number>
userName: string
@@ -140,6 +152,7 @@ export declare namespace autofill {
recordIdentifier?: string
clientDataHash: Array<number>
userVerification: UserVerification
windowXy: Position
}
export interface PasskeyAssertionResponse {
rpId: string
@@ -156,7 +169,7 @@ export declare namespace autofill {
* @param name The endpoint name to listen on. This name uniquely identifies the IPC connection and must be the same for both the server and client.
* @param callback This function will be called whenever a message is received from a client.
*/
static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void): Promise<IpcServer>
static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void): Promise<IpcServer>
/** Return the path to the IPC server. */
getPath(): string
/** Stop the IPC server. */

View File

@@ -515,6 +515,14 @@ pub mod autofill {
pub value: Result<T, BitwardenError>,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Position {
pub x: i32,
pub y: i32,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -525,6 +533,7 @@ pub mod autofill {
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub supported_algorithms: Vec<i32>,
pub window_xy: Position,
}
#[napi(object)]
@@ -541,6 +550,18 @@ pub mod autofill {
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionRequest {
pub rp_id: String,
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub allowed_credentials: Vec<Vec<u8>>,
pub window_xy: Position,
//extension_input: Vec<u8>, TODO: Implement support for extensions
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
pub rp_id: String,
pub credential_id: Vec<u8>,
pub user_name: String,
@@ -548,6 +569,7 @@ pub mod autofill {
pub record_identifier: Option<String>,
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub window_xy: Position,
}
#[napi(object)]
@@ -592,6 +614,13 @@ pub mod autofill {
(u32, u32, PasskeyAssertionRequest),
ErrorStrategy::CalleeHandled,
>,
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void"
)]
assertion_without_user_interface_callback: ThreadsafeFunction<
(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest),
ErrorStrategy::CalleeHandled,
>,
) -> napi::Result<Self> {
let (send, mut recv) = tokio::sync::mpsc::channel::<Message>(32);
tokio::spawn(async move {
@@ -628,6 +657,25 @@ pub mod autofill {
}
}
match serde_json::from_str::<
PasskeyMessage<PasskeyAssertionWithoutUserInterfaceRequest>,
>(&message)
{
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value))
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
assertion_without_user_interface_callback
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
continue;
}
Err(e) => {
println!("[ERROR] Error deserializing message1: {e}");
}
}
match serde_json::from_str::<PasskeyMessage<PasskeyRegistrationRequest>>(
&message,
) {

View File

@@ -1,22 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="17021" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23504" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="17021"/>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23504"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="CredentialProviderViewController" customModuleProvider="target">
<customObject id="-2" userLabel="File's Owner" customClass="CredentialProviderViewController" customModule="autofill_extension" customModuleProvider="target">
<connections>
<outlet property="view" destination="1" id="2"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customView translatesAutoresizingMaskIntoConstraints="NO" id="1">
<customView hidden="YES" translatesAutoresizingMaskIntoConstraints="NO" id="1">
<rect key="frame" x="0.0" y="0.0" width="378" height="94"/>
<subviews>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="1uM-r7-H1c">
<rect key="frame" x="177" y="3" width="197" height="32"/>
<rect key="frame" x="184" y="3" width="191" height="32"/>
<buttonCell key="cell" type="push" title="Return Example Password" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="2l4-PO-we5">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
@@ -28,10 +29,7 @@
</connections>
</button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="NVE-vN-dkz">
<rect key="frame" x="99" y="3" width="82" height="32"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="60" id="cP1-hK-9ZX"/>
</constraints>
<rect key="frame" x="114" y="3" width="76" height="32"/>
<buttonCell key="cell" type="push" title="Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="6Up-t3-mwm">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
@@ -39,13 +37,16 @@
Gw
</string>
</buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="60" id="cP1-hK-9ZX"/>
</constraints>
<connections>
<action selector="cancel:" target="-2" id="Qav-AK-DGt"/>
</connections>
</button>
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="aNc-0i-CWK">
<rect key="frame" x="135" y="63" width="108" height="16"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="left" title="autofill-extension" id="0xp-rC-2gr">
<rect key="frame" x="112" y="63" width="154" height="16"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="left" title="autofill-extension hello" id="0xp-rC-2gr">
<font key="font" metaFont="systemBold"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>

View File

@@ -17,17 +17,56 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
//
// If instead I make this a static, the deinit gets called correctly after each request.
// I think we still might want a static regardless, to be able to reuse the connection if possible.
static let client: MacOsProviderClient = {
let instance = MacOsProviderClient.connect()
// setup code
return instance
}()
let client: MacOsProviderClient = {
let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
// Check if the Electron app is running
let workspace = NSWorkspace.shared
let isRunning = workspace.runningApplications.contains { app in
app.bundleIdentifier == "com.bitwarden.desktop"
}
if !isRunning {
logger.log("[autofill-extension] Bitwarden Desktop not running, attempting to launch")
// Try to launch the app
if let appURL = workspace.urlForApplication(withBundleIdentifier: "com.bitwarden.desktop") {
let semaphore = DispatchSemaphore(value: 0)
workspace.openApplication(at: appURL,
configuration: NSWorkspace.OpenConfiguration()) { app, error in
if let error = error {
logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: \(error.localizedDescription)")
} else if let app = app {
logger.log("[autofill-extension] Successfully launched Bitwarden Desktop")
} else {
logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: unknown error")
}
semaphore.signal()
}
// Wait for launch completion with timeout
_ = semaphore.wait(timeout: .now() + 5.0)
// Add a small delay to allow for initialization
Thread.sleep(forTimeInterval: 1.0)
} else {
logger.error("[autofill-extension] Could not find Bitwarden Desktop app")
}
} else {
logger.log("[autofill-extension] Bitwarden Desktop is running")
}
logger.log("[autofill-extension] Connecting to Bitwarden over IPC")
return MacOsProviderClient.connect()
}()
init() {
logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
logger.log("[autofill-extension] initializing extension")
super.init(nibName: nil, bundle: nil)
}
@@ -43,40 +82,35 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
@IBAction func cancel(_ sender: AnyObject?) {
self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue))
}
@IBAction func passwordSelected(_ sender: AnyObject?) {
let passwordCredential = ASPasswordCredential(user: "j_appleseed", password: "apple1234")
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
}
/*
Implement this method if your extension supports showing credentials in the QuickType bar.
When the user selects a credential from your app, this method will be called with the
ASPasswordCredentialIdentity your app has previously saved to the ASCredentialIdentityStore.
Provide the password by completing the extension request with the associated ASPasswordCredential.
If using the credential would require showing custom UI for authenticating the user, cancel
the request with error code ASExtensionError.userInteractionRequired.
*/
// Deprecated
override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) {
logger.log("[autofill-extension] provideCredentialWithoutUserInteraction called \(credentialIdentity)")
logger.log("[autofill-extension] user \(credentialIdentity.user)")
logger.log("[autofill-extension] id \(credentialIdentity.recordIdentifier ?? "")")
logger.log("[autofill-extension] sid \(credentialIdentity.serviceIdentifier.identifier)")
logger.log("[autofill-extension] sidt \(credentialIdentity.serviceIdentifier.type.rawValue)")
private func getWindowPosition() -> Position {
let frame = self.view.window?.frame ?? .zero
let screenHeight = NSScreen.main?.frame.height ?? 0
// let databaseIsUnlocked = true
// if (databaseIsUnlocked) {
let passwordCredential = ASPasswordCredential(user: credentialIdentity.user, password: "example1234")
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
// } else {
// self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code:ASExtensionError.userInteractionRequired.rawValue))
// }
// frame.width and frame.height is always 0. Estimating works OK for now.
let estimatedWidth:CGFloat = 400;
let estimatedHeight:CGFloat = 200;
let centerX = Int32(round(frame.origin.x + estimatedWidth/2))
let centerY = Int32(round(screenHeight - (frame.origin.y + estimatedHeight/2)))
return Position(x: centerX, y:centerY)
}
override func loadView() {
let view = NSView()
// Hide the native window since we only need the IPC connection
view.isHidden = true
self.view = view
}
override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) {
let timeoutTimer = createTimer()
if let request = credentialRequest as? ASPasskeyCredentialRequest {
if let passkeyIdentity = request.credentialIdentity as? ASPasskeyCredentialIdentity {
@@ -84,11 +118,16 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
class CallbackImpl: PreparePasskeyAssertionCallback {
let ctx: ASCredentialProviderExtensionContext
required init(_ ctx: ASCredentialProviderExtensionContext) {
let logger: Logger
let timeoutTimer: DispatchWorkItem
required init(_ ctx: ASCredentialProviderExtensionContext,_ logger: Logger, _ timeoutTimer: DispatchWorkItem) {
self.ctx = ctx
self.logger = logger
self.timeoutTimer = timeoutTimer
}
func onComplete(credential: PasskeyAssertionResponse) {
self.timeoutTimer.cancel()
ctx.completeAssertionRequest(using: ASPasskeyAssertionCredential(
userHandle: credential.userHandle,
relyingParty: credential.rpId,
@@ -100,6 +139,8 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
}
func onError(error: BitwardenError) {
logger.error("[autofill-extension] OnError called, cancelling the request \(error)")
self.timeoutTimer.cancel()
ctx.cancelRequest(withError: error)
}
}
@@ -113,55 +154,74 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
UserVerification.discouraged
}
let req = PasskeyAssertionRequest(
let req = PasskeyAssertionWithoutUserInterfaceRequest(
rpId: passkeyIdentity.relyingPartyIdentifier,
credentialId: passkeyIdentity.credentialID,
userName: passkeyIdentity.userName,
userHandle: passkeyIdentity.userHandle,
recordIdentifier: passkeyIdentity.recordIdentifier,
clientDataHash: request.clientDataHash,
userVerification: userVerification
userVerification: userVerification,
windowXy: self.getWindowPosition()
)
CredentialProviderViewController.client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext))
self.client.preparePasskeyAssertionWithoutUserInterface(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
return
}
}
if let request = credentialRequest as? ASPasswordCredentialRequest {
logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2(password) called \(request)")
return;
}
timeoutTimer.cancel()
logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2 called wrong")
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Invalid authentication request"))
}
/*
Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with
ASExtensionError.userInteractionRequired. In this case, the system may present your extension's
UI and call this method. Show appropriate UI for authenticating the user then provide the password
by completing the extension request with the associated ASPasswordCredential.
override func prepareInterfaceToProvideCredential(for credentialIdentity: ASPasswordCredentialIdentity) {
}
*/
override func prepareInterfaceToProvideCredential(for credentialIdentity: ASPasswordCredentialIdentity) {
}
*/
private func createTimer() -> DispatchWorkItem {
// Create a timer for 600 second timeout
let timeoutTimer = DispatchWorkItem { [weak self] in
guard let self = self else { return }
logger.log("[autofill-extension] The operation timed out after 600 seconds")
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("The operation timed out"))
}
// Schedule the timeout
DispatchQueue.main.asyncAfter(deadline: .now() + 600, execute: timeoutTimer)
override func prepareInterfaceForExtensionConfiguration() {
logger.log("[autofill-extension] prepareInterfaceForExtensionConfiguration called")
return timeoutTimer
}
override func prepareInterface(forPasskeyRegistration registrationRequest: ASCredentialRequest) {
logger.log("[autofill-extension] prepareInterface")
let timeoutTimer = createTimer()
if let request = registrationRequest as? ASPasskeyCredentialRequest {
if let passkeyIdentity = registrationRequest.credentialIdentity as? ASPasskeyCredentialIdentity {
logger.log("[autofill-extension] prepareInterface(passkey) called \(request)")
class CallbackImpl: PreparePasskeyRegistrationCallback {
let ctx: ASCredentialProviderExtensionContext
required init(_ ctx: ASCredentialProviderExtensionContext) {
let timeoutTimer: DispatchWorkItem
let logger: Logger
required init(_ ctx: ASCredentialProviderExtensionContext, _ logger: Logger,_ timeoutTimer: DispatchWorkItem) {
self.ctx = ctx
self.logger = logger
self.timeoutTimer = timeoutTimer
}
func onComplete(credential: PasskeyRegistrationResponse) {
self.timeoutTimer.cancel()
ctx.completeRegistrationRequest(using: ASPasskeyRegistrationCredential(
relyingParty: credential.rpId,
clientDataHash: credential.clientDataHash,
@@ -169,58 +229,99 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
attestationObject: credential.attestationObject
))
}
func onError(error: BitwardenError) {
logger.error("[autofill-extension] OnError called, cancelling the request \(error)")
self.timeoutTimer.cancel()
ctx.cancelRequest(withError: error)
}
}
let userVerification = switch request.userVerificationPreference {
case .preferred:
UserVerification.preferred
case .required:
UserVerification.required
default:
UserVerification.discouraged
case .preferred:
UserVerification.preferred
case .required:
UserVerification.required
default:
UserVerification.discouraged
}
let req = PasskeyRegistrationRequest(
rpId: passkeyIdentity.relyingPartyIdentifier,
userName: passkeyIdentity.userName,
userHandle: passkeyIdentity.userHandle,
clientDataHash: request.clientDataHash,
userVerification: userVerification,
supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) }
supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) },
windowXy: self.getWindowPosition()
)
CredentialProviderViewController.client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext))
logger.log("[autofill-extension] prepareInterface(passkey) calling preparePasskeyRegistration")
self.client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
return
}
}
logger.log("[autofill-extension] We didn't get a passkey")
timeoutTimer.cancel()
// If we didn't get a passkey, return an error
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Invalid registration request"))
}
/*
Prepare your UI to list available credentials for the user to choose from. The items in
'serviceIdentifiers' describe the service the user is logging in to, so your extension can
prioritize the most relevant credentials in the list.
*/
override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) {
logger.log("[autofill-extension] prepareCredentialList for serviceIdentifiers: \(serviceIdentifiers.count)")
for serviceIdentifier in serviceIdentifiers {
logger.log(" service: \(serviceIdentifier.identifier)")
}
}
override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier], requestParameters: ASPasskeyCredentialRequestParameters) {
logger.log("[autofill-extension] prepareCredentialList(passkey) for serviceIdentifiers: \(serviceIdentifiers.count)")
logger.log("request parameters: \(requestParameters.relyingPartyIdentifier)")
for serviceIdentifier in serviceIdentifiers {
logger.log(" service: \(serviceIdentifier.identifier)")
class CallbackImpl: PreparePasskeyAssertionCallback {
let ctx: ASCredentialProviderExtensionContext
let timeoutTimer: DispatchWorkItem
let logger: Logger
required init(_ ctx: ASCredentialProviderExtensionContext,_ logger: Logger, _ timeoutTimer: DispatchWorkItem) {
self.ctx = ctx
self.logger = logger
self.timeoutTimer = timeoutTimer
}
func onComplete(credential: PasskeyAssertionResponse) {
self.timeoutTimer.cancel()
ctx.completeAssertionRequest(using: ASPasskeyAssertionCredential(
userHandle: credential.userHandle,
relyingParty: credential.rpId,
signature: credential.signature,
clientDataHash: credential.clientDataHash,
authenticatorData: credential.authenticatorData,
credentialID: credential.credentialId
))
}
func onError(error: BitwardenError) {
logger.error("[autofill-extension] OnError called, cancelling the request \(error)")
self.timeoutTimer.cancel()
ctx.cancelRequest(withError: error)
}
}
}
let userVerification = switch requestParameters.userVerificationPreference {
case .preferred:
UserVerification.preferred
case .required:
UserVerification.required
default:
UserVerification.discouraged
}
let req = PasskeyAssertionRequest(
rpId: requestParameters.relyingPartyIdentifier,
clientDataHash: requestParameters.clientDataHash,
userVerification: userVerification,
allowedCredentials: requestParameters.allowedCredentials,
windowXy: self.getWindowPosition()
//extensionInput: requestParameters.extensionInput,
)
let timeoutTimer = createTimer()
self.client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
return
}
}

View File

@@ -182,6 +182,10 @@ const routes: Routes = [
path: "passkeys",
component: Fido2PlaceholderComponent,
},
{
path: "passkeys",
component: Fido2PlaceholderComponent,
},
{
path: "",
component: AnonLayoutWrapperComponent,

View File

@@ -1,16 +1,45 @@
import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { BehaviorSubject, Observable } from "rxjs";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../autofill/services/desktop-fido2-user-interface.service";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
@Component({
standalone: true,
imports: [CommonModule],
template: `
<div
style="background:white; display:flex; justify-content: center; align-items: center; flex-direction: column"
>
<h1 style="color: black">Select your passkey</h1>
<div *ngFor="let item of cipherIds$ | async">
<button
style="color:black; padding: 10px 20px; border: 1px solid blue; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="chooseCipher(item)"
>
{{ item }}
</button>
</div>
<br />
<button
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="confirmPasskey()"
>
Confirm passkey
</button>
<button
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
bitButton
@@ -23,14 +52,69 @@ import { DesktopSettingsService } from "../../platform/services/desktop-settings
</div>
`,
})
export class Fido2PlaceholderComponent {
export class Fido2PlaceholderComponent implements OnInit, OnDestroy {
session?: DesktopFido2UserInterfaceSession = null;
private cipherIdsSubject = new BehaviorSubject<string[]>([]);
cipherIds$: Observable<string[]>;
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly router: Router,
) {}
ngOnInit() {
this.session = this.fido2UserInterfaceService.getCurrentSession();
this.cipherIds$ = this.session?.availableCipherIds$;
}
async chooseCipher(cipherId: string) {
// For now: Set UV to true
this.session?.confirmChosenCipher(cipherId, true);
await this.router.navigate(["/"]);
await this.desktopSettingsService.setModalMode(false);
}
ngOnDestroy() {
this.cipherIdsSubject.complete(); // Clean up the BehaviorSubject
}
async confirmPasskey() {
try {
// Retrieve the current UI session to control the flow
if (!this.session) {
// todo: handle error
throw new Error("No session found");
}
// If we want to we could submit information to the session in order to create the credential
// const cipher = await session.createCredential({
// userHandle: "userHandle2",
// userName: "username2",
// credentialName: "zxsd2",
// rpId: "webauthn.io",
// userVerification: true,
// });
this.session.notifyConfirmNewCredential(true);
// Not sure this clean up should happen here or in session.
// The session currently toggles modal on and send us here
// But if this route is somehow opened outside of session we want to make sure we clean up?
await this.router.navigate(["/"]);
await this.desktopSettingsService.setModalMode(false);
} catch {
// TODO: Handle error appropriately
}
}
async closeModal() {
await this.router.navigate(["/"]);
await this.desktopSettingsService.setInModalMode(false);
await this.desktopSettingsService.setModalMode(false);
this.session.notifyConfirmNewCredential(false);
// little bit hacky:
this.session.confirmChosenCipher(null);
}
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { APP_INITIALIZER, NgModule } from "@angular/core";
import { Router } from "@angular/router";
import { Subject, merge } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
@@ -334,9 +335,21 @@ const safeProviders: SafeProvider[] = [
],
}),
safeProvider({
provide: Fido2UserInterfaceServiceAbstraction,
provide: DesktopFido2UserInterfaceService,
useClass: DesktopFido2UserInterfaceService,
deps: [AuthServiceAbstraction, CipherServiceAbstraction, AccountService, LogService],
deps: [
AuthServiceAbstraction,
CipherServiceAbstraction,
AccountService,
LogService,
MessagingServiceAbstraction,
Router,
DesktopSettingsService,
],
}),
safeProvider({
provide: Fido2UserInterfaceServiceAbstraction, // We utilize desktop specific methods when wiring OS API's
useExisting: DesktopFido2UserInterfaceService,
}),
safeProvider({
provide: Fido2AuthenticatorServiceAbstraction,

View File

@@ -80,6 +80,44 @@ export default {
return;
}
ipcRenderer.send("autofill.completePasskeyAssertion", {
clientId,
sequenceNumber,
response,
});
});
},
);
},
listenPasskeyAssertionWithoutUserInterface: (
fn: (
clientId: number,
sequenceNumber: number,
request: autofill.PasskeyAssertionWithoutUserInterfaceRequest,
completeCallback: (error: Error | null, response: autofill.PasskeyAssertionResponse) => void,
) => void,
) => {
ipcRenderer.on(
"autofill.passkeyAssertionWithoutUserInterface",
(
event,
data: {
clientId: number;
sequenceNumber: number;
request: autofill.PasskeyAssertionWithoutUserInterfaceRequest;
},
) => {
const { clientId, sequenceNumber, request } = data;
fn(clientId, sequenceNumber, request, (error, response) => {
if (error) {
ipcRenderer.send("autofill.completeError", {
clientId,
sequenceNumber,
error: error.message,
});
return;
}
ipcRenderer.send("autofill.completePasskeyAssertion", {
clientId,
sequenceNumber,

View File

@@ -1,7 +1,6 @@
import { Injectable, OnDestroy } from "@angular/core";
import { autofill } from "desktop_native/napi";
import {
EMPTY,
Subject,
distinctUntilChanged,
filter,
@@ -10,6 +9,7 @@ import {
mergeMap,
switchMap,
takeUntil,
EMPTY,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -26,9 +26,9 @@ import {
} from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { parseCredentialId } from "@bitwarden/common/platform/services/fido2/credential-id-utils";
import { getCredentialsForAutofill } from "@bitwarden/common/platform/services/fido2/fido2-autofill-utils";
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
import { guidToRawFormat } from "@bitwarden/common/platform/services/fido2/guid-utils";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -41,6 +41,8 @@ import {
NativeAutofillSyncCommand,
} from "../../platform/main/autofill/sync.command";
import type { NativeWindowObject } from "./desktop-fido2-user-interface.service";
@Injectable()
export class DesktopAutofillService implements OnDestroy {
private destroy$ = new Subject<void>();
@@ -49,7 +51,7 @@ export class DesktopAutofillService implements OnDestroy {
private logService: LogService,
private cipherService: CipherService,
private configService: ConfigService,
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<void>,
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>,
private accountService: AccountService,
) {}
@@ -155,7 +157,11 @@ export class DesktopAutofillService implements OnDestroy {
const controller = new AbortController();
void this.fido2AuthenticatorService
.makeCredential(this.convertRegistrationRequest(request), null, controller)
.makeCredential(
this.convertRegistrationRequest(request),
{ windowXy: request.windowXy },
controller,
)
.then((response) => {
callback(null, this.convertRegistrationResponse(request, response));
})
@@ -165,47 +171,77 @@ export class DesktopAutofillService implements OnDestroy {
});
});
ipc.autofill.listenPasskeyAssertionWithoutUserInterface(
async (clientId, sequenceNumber, request, callback) => {
this.logService.warning(
"listenPasskeyAssertion without user interface",
clientId,
sequenceNumber,
request,
);
// For some reason the credentialId is passed as an empty array in the request, so we need to
// get it from the cipher. For that we use the recordIdentifier, which is the cipherId.
if (request.recordIdentifier && request.credentialId.length === 0) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (!activeUserId) {
this.logService.error("listenPasskeyAssertion error", "Active user not found");
callback(new Error("Active user not found"), null);
return;
}
const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId);
if (!cipher) {
this.logService.error("listenPasskeyAssertion error", "Cipher not found");
callback(new Error("Cipher not found"), null);
return;
}
const decrypted = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
const fido2Credential = decrypted.login.fido2Credentials?.[0];
if (!fido2Credential) {
this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found");
callback(new Error("Fido2Credential not found"), null);
return;
}
request.credentialId = Array.from(
parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId),
);
}
const controller = new AbortController();
void this.fido2AuthenticatorService
.getAssertion(
this.convertAssertionRequest(request),
{ windowXy: request.windowXy },
controller,
)
.then((response) => {
callback(null, this.convertAssertionResponse(request, response));
})
.catch((error) => {
this.logService.error("listenPasskeyAssertion error", error);
callback(error, null);
});
},
);
ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => {
this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request);
// TODO: For some reason the credentialId is passed as an empty array in the request, so we need to
// get it from the cipher. For that we use the recordIdentifier, which is the cipherId.
if (request.recordIdentifier && request.credentialId.length === 0) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (!activeUserId) {
this.logService.error("listenPasskeyAssertion error", "Active user not found");
callback(new Error("Active user not found"), null);
return;
}
const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId);
if (!cipher) {
this.logService.error("listenPasskeyAssertion error", "Cipher not found");
callback(new Error("Cipher not found"), null);
return;
}
const decrypted = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
const fido2Credential = decrypted.login.fido2Credentials?.[0];
if (!fido2Credential) {
this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found");
callback(new Error("Fido2Credential not found"), null);
return;
}
request.credentialId = Array.from(
guidToRawFormat(decrypted.login.fido2Credentials?.[0].credentialId),
);
}
const controller = new AbortController();
void this.fido2AuthenticatorService
.getAssertion(this.convertAssertionRequest(request), null, controller)
.getAssertion(
this.convertAssertionRequest(request),
{ windowXy: request.windowXy },
controller,
)
.then((response) => {
callback(null, this.convertAssertionResponse(request, response));
})
@@ -257,27 +293,48 @@ export class DesktopAutofillService implements OnDestroy {
};
}
/**
*
* @param request
* @param assumeUserPresence For WithoutUserInterface requests, we assume the user is present
* @returns
*/
private convertAssertionRequest(
request: autofill.PasskeyAssertionRequest,
request:
| autofill.PasskeyAssertionRequest
| autofill.PasskeyAssertionWithoutUserInterfaceRequest,
): Fido2AuthenticatorGetAssertionParams {
let allowedCredentials;
if ("credentialId" in request) {
allowedCredentials = [
{
id: new Uint8Array(request.credentialId),
type: "public-key" as const,
},
];
} else {
allowedCredentials = request.allowedCredentials.map((credentialId) => ({
id: new Uint8Array(credentialId),
type: "public-key" as const,
}));
}
return {
rpId: request.rpId,
hash: new Uint8Array(request.clientDataHash),
allowCredentialDescriptorList: [
{
id: new Uint8Array(request.credentialId),
type: "public-key",
},
],
allowCredentialDescriptorList: allowedCredentials,
extensions: {},
requireUserVerification:
request.userVerification === "required" || request.userVerification === "preferred",
fallbackSupported: false,
assumeUserPresence: true, // For desktop assertions, it's safe to assume UP has been checked by OS dialogues
};
}
private convertAssertionResponse(
request: autofill.PasskeyAssertionRequest,
request:
| autofill.PasskeyAssertionRequest
| autofill.PasskeyAssertionWithoutUserInterfaceRequest,
response: Fido2AuthenticatorGetAssertionResult,
): autofill.PasskeyAssertionResponse {
return {

View File

@@ -1,4 +1,14 @@
import { firstValueFrom, map } from "rxjs";
import { Router } from "@angular/router";
import {
lastValueFrom,
firstValueFrom,
map,
Subject,
filter,
take,
BehaviorSubject,
timeout,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
@@ -10,8 +20,10 @@ import {
PickCredentialParams,
} from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
@@ -19,28 +31,54 @@ import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
/**
* This type is used to pass the window position from the native UI
*/
export type NativeWindowObject = {
/**
* The position of the window, first entry is the x position, second is the y position
*/
windowXy?: { x: number; y: number };
};
export class DesktopFido2UserInterfaceService
implements Fido2UserInterfaceServiceAbstraction<void>
implements Fido2UserInterfaceServiceAbstraction<NativeWindowObject>
{
constructor(
private authService: AuthService,
private cipherService: CipherService,
private accountService: AccountService,
private logService: LogService,
private messagingService: MessagingService,
private router: Router,
private desktopSettingsService: DesktopSettingsService,
) {}
private currentSession: any;
getCurrentSession(): DesktopFido2UserInterfaceSession | undefined {
return this.currentSession;
}
async newSession(
fallbackSupported: boolean,
_tab: void,
nativeWindowObject: NativeWindowObject,
abortController?: AbortController,
): Promise<DesktopFido2UserInterfaceSession> {
this.logService.warning("newSession", fallbackSupported, abortController);
return new DesktopFido2UserInterfaceSession(
this.logService.warning("newSession", fallbackSupported, abortController, nativeWindowObject);
const session = new DesktopFido2UserInterfaceSession(
this.authService,
this.cipherService,
this.accountService,
this.logService,
this.router,
this.desktopSettingsService,
nativeWindowObject,
);
this.currentSession = session;
return session;
}
}
@@ -50,17 +88,110 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
private cipherService: CipherService,
private accountService: AccountService,
private logService: LogService,
private router: Router,
private desktopSettingsService: DesktopSettingsService,
private windowObject: NativeWindowObject,
) {}
private confirmCredentialSubject = new Subject<boolean>();
private createdCipher: Cipher;
private availableCipherIdsSubject = new BehaviorSubject<string[]>(null);
/**
* Observable that emits available cipher IDs once they're confirmed by the UI
*/
availableCipherIds$ = this.availableCipherIdsSubject.pipe(
filter((ids) => ids != null),
take(1),
);
private chosenCipherSubject = new Subject<{ cipherId: string; userVerified: boolean }>();
// Method implementation
async pickCredential({
cipherIds,
userVerification,
assumeUserPresence,
masterPasswordRepromptRequired,
}: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
this.logService.warning("pickCredential", cipherIds, userVerification);
this.logService.warning("pickCredential desktop function", {
cipherIds,
userVerification,
assumeUserPresence,
masterPasswordRepromptRequired,
});
return { cipherId: cipherIds[0], userVerified: userVerification };
try {
// Check if we can return the credential without user interaction
if (assumeUserPresence && cipherIds.length === 1 && !masterPasswordRepromptRequired) {
this.logService.debug(
"shortcut - Assuming user presence and returning cipherId",
cipherIds[0],
);
return { cipherId: cipherIds[0], userVerified: userVerification };
}
this.logService.debug("Could not shortcut, showing UI");
// make the cipherIds available to the UI.
this.availableCipherIdsSubject.next(cipherIds);
await this.showUi("/passkeys", this.windowObject.windowXy);
const chosenCipherResponse = await this.waitForUiChosenCipher();
this.logService.debug("Received chosen cipher", chosenCipherResponse);
return {
cipherId: chosenCipherResponse.cipherId,
userVerified: chosenCipherResponse.userVerified,
};
} finally {
// Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setModalMode(false);
}
}
confirmChosenCipher(cipherId: string, userVerified: boolean = false): void {
this.chosenCipherSubject.next({ cipherId, userVerified });
this.chosenCipherSubject.complete();
}
private async waitForUiChosenCipher(
timeoutMs: number = 60000,
): Promise<{ cipherId: string; userVerified: boolean } | undefined> {
try {
return await lastValueFrom(this.chosenCipherSubject.pipe(timeout(timeoutMs)));
} catch {
// If we hit a timeout, return undefined instead of throwing
this.logService.warning("Timeout: User did not select a cipher within the allowed time", {
timeoutMs,
});
return { cipherId: undefined, userVerified: false };
}
}
/**
* Notifies the Fido2UserInterfaceSession that the UI operations has completed and it can return to the OS.
*/
notifyConfirmNewCredential(confirmed: boolean): void {
this.confirmCredentialSubject.next(confirmed);
this.confirmCredentialSubject.complete();
}
/**
* Returns once the UI has confirmed and completed the operation
* @returns
*/
private async waitForUiNewCredentialConfirmation(): Promise<boolean> {
return lastValueFrom(this.confirmCredentialSubject);
}
/**
* This is called by the OS. It loads the UI and waits for the user to confirm the new credential. Once the UI has confirmed, it returns to the the OS.
* @param param0
* @returns
*/
async confirmNewCredential({
credentialName,
userName,
@@ -75,6 +206,48 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
rpId,
);
try {
await this.showUi("/passkeys", this.windowObject.windowXy);
// Wait for the UI to wrap up
const confirmation = await this.waitForUiNewCredentialConfirmation();
if (!confirmation) {
return { cipherId: undefined, userVerified: false };
}
// Create the credential
await this.createCredential({
credentialName,
userName,
rpId,
userHandle: "",
userVerification,
});
// wait for 10ms to help RXJS catch up(?)
// We sometimes get a race condition from this.createCredential not updating cipherService in time
//console.log("waiting 10ms..");
//await new Promise((resolve) => setTimeout(resolve, 10));
//console.log("Just waited 10ms");
// Return the new cipher (this.createdCipher)
return { cipherId: this.createdCipher.id, userVerified: userVerification };
} finally {
// Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setModalMode(false);
}
}
private async showUi(route: string, position?: { x: number; y: number }): Promise<void> {
// Load the UI:
await this.desktopSettingsService.setModalMode(true, position);
await this.router.navigate(["/passkeys"]);
}
/**
* Can be called by the UI to create a new credential with user input etc.
* @param param0
*/
async createCredential({ credentialName, userName, rpId }: NewCredentialParams): Promise<Cipher> {
// Store the passkey on a new cipher to avoid replacing something important
const cipher = new CipherView();
cipher.name = credentialName;
@@ -97,7 +270,9 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
const createdCipher = await this.cipherService.createWithServer(encCipher);
return { cipherId: createdCipher.id, userVerified: userVerification };
this.createdCipher = createdCipher;
return createdCipher;
}
async informExcludedCredential(existingCipherIds: string[]): Promise<void> {

View File

@@ -209,6 +209,14 @@ export class Main {
new ElectronMainMessagingService(this.windowMain),
);
this.trayMain = new TrayMain(
this.windowMain,
this.i18nService,
this.desktopSettingsService,
this.messagingService,
this.biometricsService,
);
messageSubject.asObservable().subscribe((message) => {
void this.messagingMain.onMessage(message).catch((err) => {
this.logService.error(
@@ -236,7 +244,7 @@ export class Main {
this.windowMain,
this.i18nService,
this.desktopSettingsService,
biometricStateService,
this.messagingService,
this.biometricsService,
);
@@ -285,7 +293,7 @@ export class Main {
async () => {
await this.toggleHardwareAcceleration();
// Reset modal mode to make sure main window is displayed correctly
await this.desktopSettingsService.resetInModalMode();
await this.desktopSettingsService.resetModalMode();
await this.windowMain.init();
await this.i18nService.init();
await this.messagingMain.init();

View File

@@ -37,6 +37,10 @@ export class MessagingMain {
async onMessage(message: any) {
switch (message.command) {
case "loadurl":
// TODO: Remove this once fakepopup is removed from tray (just used for dev)
await this.main.windowMain.loadUrl(message.url, message.modal);
break;
case "scheduleNextSync":
this.scheduleNextSync();
break;

View File

@@ -1,16 +1,16 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as path from "path";
import * as url from "url";
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron";
import { firstValueFrom } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BiometricStateService, BiometricsService } from "@bitwarden/key-management";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { BiometricsService } from "@bitwarden/key-management";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { cleanUserAgent, isDev } from "../utils";
import { isDev } from "../utils";
import { WindowMain } from "./window.main";
@@ -26,7 +26,7 @@ export class TrayMain {
private windowMain: WindowMain,
private i18nService: I18nService,
private desktopSettingsService: DesktopSettingsService,
private biometricsStateService: BiometricStateService,
private messagingService: MessagingService,
private biometricService: BiometricsService,
) {
if (process.platform === "win32") {
@@ -216,32 +216,6 @@ export class TrayMain {
* @returns
*/
private async fakePopup() {
if (this.windowMain.win == null || this.windowMain.win.isDestroyed()) {
await this.windowMain.createWindow("modal-app");
return;
}
// Restyle existing
const existingWin = this.windowMain.win;
await this.desktopSettingsService.setInModalMode(true);
await existingWin.loadURL(
url.format({
protocol: "file:",
//pathname: `${__dirname}/index.html`,
pathname: path.join(__dirname, "/index.html"),
slashes: true,
hash: "/passkeys",
query: {
redirectUrl: "/passkeys",
},
}),
{
userAgent: cleanUserAgent(existingWin.webContents.userAgent),
},
);
existingWin.once("ready-to-show", () => {
existingWin.show();
});
await this.messagingService.send("loadurl", { url: "/passkeys", modal: true });
}
}

View File

@@ -78,18 +78,19 @@ export class WindowMain {
}
});
this.desktopSettingsService.inModalMode$
this.desktopSettingsService.modalMode$
.pipe(
pairwise(),
concatMap(async ([lastValue, newValue]) => {
if (lastValue && !newValue) {
if (lastValue.isModalModeActive && !newValue.isModalModeActive) {
// Reset the window state to the main window state
applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
// Because modal is used in front of another app, UX wise it makes sense to hide the main window when leaving modal mode.
this.win.hide();
} else if (!lastValue && newValue) {
} else if (!lastValue.isModalModeActive && newValue.isModalModeActive) {
// Apply the popup modal styles
applyPopupModalStyles(this.win);
this.logService.info("Applying popup modal styles", newValue.modalPosition);
applyPopupModalStyles(this.win, newValue.modalPosition);
this.win.show();
}
}),
@@ -209,6 +210,35 @@ export class WindowMain {
}
}
// TODO: REMOVE ONCE WE CAN STOP USING FAKE POP UP BTN FROM TRAY
// Only used for development
async loadUrl(targetPath: string, modal: boolean = false) {
if (this.win == null || this.win.isDestroyed()) {
await this.createWindow("modal-app");
return;
}
await this.desktopSettingsService.setModalMode(modal);
await this.win.loadURL(
url.format({
protocol: "file:",
//pathname: `${__dirname}/index.html`,
pathname: path.join(__dirname, "/index.html"),
slashes: true,
hash: targetPath,
query: {
redirectUrl: targetPath,
},
}),
{
userAgent: cleanUserAgent(this.win.webContents.userAgent),
},
);
this.win.once("ready-to-show", () => {
this.win.show();
});
}
/**
* Creates the main window. The template argument is used to determine the styling of the window and what url will be loaded.
* When the template is "modal-app", the window will be styled as a modal and the passkeys page will be loaded.
@@ -394,9 +424,9 @@ export class WindowMain {
return;
}
const inModalMode = await firstValueFrom(this.desktopSettingsService.inModalMode$);
const modalMode = await firstValueFrom(this.desktopSettingsService.modalMode$);
if (inModalMode) {
if (modalMode.isModalModeActive) {
return;
}

View File

@@ -40,6 +40,7 @@ export class NativeAutofillMain {
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.registration", error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
return;
}
this.windowMain.win.webContents.send("autofill.passkeyRegistration", {
@@ -52,6 +53,7 @@ export class NativeAutofillMain {
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.assertion", error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
return;
}
this.windowMain.win.webContents.send("autofill.passkeyAssertion", {
@@ -60,6 +62,19 @@ export class NativeAutofillMain {
request,
});
},
// AssertionWithoutUserInterfaceCallback
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.assertion", error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
return;
}
this.windowMain.win.webContents.send("autofill.passkeyAssertionWithoutUserInterface", {
clientId,
sequenceNumber,
request,
});
},
);
ipcMain.on("autofill.completePasskeyRegistration", (event, data) => {
@@ -77,7 +92,7 @@ export class NativeAutofillMain {
ipcMain.on("autofill.completeError", (event, data) => {
this.logService.warning("autofill.completeError", data);
const { clientId, sequenceNumber, error } = data;
this.ipcServer.completeAssertion(clientId, sequenceNumber, error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
});
}

View File

@@ -11,3 +11,8 @@ export class WindowState {
y?: number;
zoomFactor?: number;
}
export class ModalModeState {
isModalModeActive: boolean;
modalPosition?: { x: number; y: number }; // Modal position is often passed from the native UI
}

View File

@@ -6,10 +6,11 @@ import { WindowState } from "./models/domain/window-state";
const popupWidth = 680;
const popupHeight = 500;
export function applyPopupModalStyles(window: BrowserWindow) {
type Position = { x: number; y: number };
export function applyPopupModalStyles(window: BrowserWindow, position?: Position) {
window.unmaximize();
window.setSize(popupWidth, popupHeight);
window.center();
window.setWindowButtonVisibility?.(false);
window.setMenuBarVisibility?.(false);
window.setResizable(false);
@@ -20,8 +21,21 @@ export function applyPopupModalStyles(window: BrowserWindow) {
window.setFullScreen(false);
window.once("leave-full-screen", () => {
window.setSize(popupWidth, popupHeight);
window.center();
positionWindow(window, position);
});
} else {
// If not in full screen
positionWindow(window, position);
}
}
function positionWindow(window: BrowserWindow, position?: Position) {
if (position) {
const centeredX = position.x - popupWidth / 2;
const centeredY = position.y - popupHeight / 2;
window.setPosition(centeredX, centeredY);
} else {
window.center();
}
}

View File

@@ -8,7 +8,7 @@ import {
} from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { WindowState } from "../models/domain/window-state";
import { ModalModeState, WindowState } from "../models/domain/window-state";
export const HARDWARE_ACCELERATION = new KeyDefinition<boolean>(
DESKTOP_SETTINGS_DISK,
@@ -75,7 +75,7 @@ const MINIMIZE_ON_COPY = new UserKeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "
clearOn: [], // User setting, no need to clear
});
const IN_MODAL_MODE = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "inModalMode", {
const MODAL_MODE = new KeyDefinition<ModalModeState>(DESKTOP_SETTINGS_DISK, "modalMode", {
deserializer: (b) => b,
});
@@ -174,9 +174,9 @@ export class DesktopSettingsService {
*/
minimizeOnCopy$ = this.minimizeOnCopyState.state$.pipe(map(Boolean));
private readonly inModalModeState = this.stateProvider.getGlobal(IN_MODAL_MODE);
private readonly modalModeState = this.stateProvider.getGlobal(MODAL_MODE);
inModalMode$ = this.inModalModeState.state$.pipe(map(Boolean));
modalMode$ = this.modalModeState.state$;
constructor(private stateProvider: StateProvider) {
this.window$ = this.windowState.state$.pipe(
@@ -190,8 +190,8 @@ export class DesktopSettingsService {
* This is used to clear the setting on application start to make sure we don't end up
* stuck in modal mode if the application is force-closed in modal mode.
*/
async resetInModalMode() {
await this.inModalModeState.update(() => false);
async resetModalMode() {
await this.modalModeState.update(() => ({ isModalModeActive: false }));
}
async setHardwareAcceleration(enabled: boolean) {
@@ -306,8 +306,11 @@ export class DesktopSettingsService {
* Sets the modal mode of the application. Setting this changes the windows-size and other properties.
* @param value `true` if the application is in modal mode, `false` if it is not.
*/
async setInModalMode(value: boolean) {
await this.inModalModeState.update(() => value);
async setModalMode(value: boolean, modalPosition?: { x: number; y: number }) {
await this.modalModeState.update(() => ({
isModalModeActive: value,
modalPosition,
}));
}
/**

View File

@@ -82,7 +82,7 @@ export abstract class Fido2UserInterfaceSession {
*
* @param params The parameters to use when asking the user to pick a credential.
* @param abortController An abort controller that can be used to cancel/close the session.
* @returns The ID of the cipher that contains the credentials the user picked.
* @returns The ID of the cipher that contains the credentials the user picked. If not cipher was picked, return cipherId = undefined to to let the authenticator throw the error.
*/
pickCredential: (
params: PickCredentialParams,