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

Merge branch 'main' into PM-26250-Explore-options-to-enable-direct-importer-for-mac-app-store-build

This commit is contained in:
John Harrington
2025-12-05 16:49:23 -07:00
committed by GitHub
84 changed files with 2580 additions and 614 deletions

2
.github/CODEOWNERS vendored
View File

@@ -8,7 +8,9 @@
apps/desktop/desktop_native @bitwarden/team-platform-dev apps/desktop/desktop_native @bitwarden/team-platform-dev
apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-desktop-dev apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/macos_provider @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-management-dev apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-management-dev
## No ownership for Cargo.lock and Cargo.toml to allow dependency updates ## No ownership for Cargo.lock and Cargo.toml to allow dependency updates
apps/desktop/desktop_native/Cargo.lock apps/desktop/desktop_native/Cargo.lock
apps/desktop/desktop_native/Cargo.toml apps/desktop/desktop_native/Cargo.toml

View File

@@ -1022,7 +1022,7 @@ jobs:
python-version: '3.14' python-version: '3.14'
- name: Set up Node-gyp - name: Set up Node-gyp
run: python3 -m pip install setuptools run: python -m pip install setuptools
- name: Cache Rust dependencies - name: Cache Rust dependencies
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
@@ -1038,6 +1038,7 @@ jobs:
rustup show rustup show
echo "GitHub ref: $GITHUB_REF" echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT" echo "GitHub event: $GITHUB_EVENT"
xcodebuild -showsdks
- name: Cache Build - name: Cache Build
id: build-cache id: build-cache
@@ -1259,7 +1260,7 @@ jobs:
python-version: '3.14' python-version: '3.14'
- name: Set up Node-gyp - name: Set up Node-gyp
run: python3 -m pip install setuptools run: python -m pip install setuptools
- name: Cache Rust dependencies - name: Cache Rust dependencies
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
@@ -1275,6 +1276,7 @@ jobs:
rustup show rustup show
echo "GitHub ref: $GITHUB_REF" echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT" echo "GitHub event: $GITHUB_EVENT"
xcodebuild -showsdks
- name: Get Build Cache - name: Get Build Cache
id: build-cache id: build-cache
@@ -1531,7 +1533,7 @@ jobs:
python-version: '3.14' python-version: '3.14'
- name: Set up Node-gyp - name: Set up Node-gyp
run: python3 -m pip install setuptools run: python -m pip install setuptools
- name: Cache Rust dependencies - name: Cache Rust dependencies
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
@@ -1547,6 +1549,7 @@ jobs:
rustup show rustup show
echo "GitHub ref: $GITHUB_REF" echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT" echo "GitHub event: $GITHUB_EVENT"
xcodebuild -showsdks
- name: Get Build Cache - name: Get Build Cache
id: build-cache id: build-cache

View File

@@ -5848,8 +5848,8 @@
"andMoreFeatures": { "andMoreFeatures": {
"message": "And more!" "message": "And more!"
}, },
"planDescPremium": { "advancedOnlineSecurity": {
"message": "Complete online security" "message": "Advanced online security"
}, },
"upgradeToPremium": { "upgradeToPremium": {
"message": "Upgrade to Premium" "message": "Upgrade to Premium"

View File

@@ -24,6 +24,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { SecureNoteType, CipherType } from "@bitwarden/common/vault/enums"; import { SecureNoteType, CipherType } from "@bitwarden/common/vault/enums";
@@ -198,7 +199,7 @@ export class Fido2Component implements OnInit, OnDestroy {
this.displayedCiphers = this.ciphers.filter( this.displayedCiphers = this.ciphers.filter(
(cipher) => (cipher) =>
cipher.login.matchesUri(this.url, equivalentDomains) && cipher.login.matchesUri(this.url, equivalentDomains) &&
this.cipherHasNoOtherPasskeys(cipher, message.userHandle), Fido2Utils.cipherHasNoOtherPasskeys(cipher, message.userHandle),
); );
this.passkeyAction = PasskeyActions.Register; this.passkeyAction = PasskeyActions.Register;
@@ -472,16 +473,4 @@ export class Fido2Component implements OnInit, OnDestroy {
...msg, ...msg,
}); });
} }
/**
* This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle
* @param userHandle
*/
private cipherHasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean {
if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) {
return true;
}
return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle);
}
} }

View File

@@ -294,19 +294,11 @@ export default class RuntimeBackground {
await this.openPopup(); await this.openPopup();
break; break;
case VaultMessages.OpenAtRiskPasswords: { case VaultMessages.OpenAtRiskPasswords: {
if (await this.shouldRejectManyOriginMessage(msg)) {
return;
}
await this.main.openAtRisksPasswordsPage(); await this.main.openAtRisksPasswordsPage();
this.announcePopupOpen(); this.announcePopupOpen();
break; break;
} }
case VaultMessages.OpenBrowserExtensionToUrl: { case VaultMessages.OpenBrowserExtensionToUrl: {
if (await this.shouldRejectManyOriginMessage(msg)) {
return;
}
await this.main.openTheExtensionToPage(msg.url); await this.main.openTheExtensionToPage(msg.url);
this.announcePopupOpen(); this.announcePopupOpen();
break; break;

View File

@@ -180,7 +180,7 @@ describe("VaultV2Component", () => {
const nudgesSvc = { const nudgesSvc = {
showNudgeSpotlight$: jest.fn().mockImplementation((_type: NudgeType) => of(false)), showNudgeSpotlight$: jest.fn().mockImplementation((_type: NudgeType) => of(false)),
dismissNudge: jest.fn().mockResolvedValue(undefined), dismissNudge: jest.fn().mockResolvedValue(undefined),
} as Partial<NudgesService>; };
const dialogSvc = {} as Partial<DialogService>; const dialogSvc = {} as Partial<DialogService>;
@@ -209,6 +209,10 @@ describe("VaultV2Component", () => {
.mockResolvedValue(new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)), // 8 days ago .mockResolvedValue(new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)), // 8 days ago
}; };
const configSvc = {
getFeatureFlag$: jest.fn().mockImplementation((_flag: string) => of(false)),
};
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@@ -256,9 +260,7 @@ describe("VaultV2Component", () => {
{ provide: StateProvider, useValue: mock<StateProvider>() }, { provide: StateProvider, useValue: mock<StateProvider>() },
{ {
provide: ConfigService, provide: ConfigService,
useValue: { useValue: configSvc,
getFeatureFlag$: (_: string) => of(false),
},
}, },
{ {
provide: SearchService, provide: SearchService,
@@ -453,7 +455,9 @@ describe("VaultV2Component", () => {
hasPremiumFromAnySource$.next(false); hasPremiumFromAnySource$.next(false);
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => configSvc.getFeatureFlag$.mockImplementation((_flag: string) => of(true));
nudgesSvc.showNudgeSpotlight$.mockImplementation((type: NudgeType) =>
of(type === NudgeType.PremiumUpgrade), of(type === NudgeType.PremiumUpgrade),
); );
@@ -482,9 +486,11 @@ describe("VaultV2Component", () => {
})); }));
it("renders Empty-Vault spotlight when vaultState is Empty and nudge is on", fakeAsync(() => { it("renders Empty-Vault spotlight when vaultState is Empty and nudge is on", fakeAsync(() => {
configSvc.getFeatureFlag$.mockImplementation((_flag: string) => of(false));
itemsSvc.emptyVault$.next(true); itemsSvc.emptyVault$.next(true);
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => { nudgesSvc.showNudgeSpotlight$.mockImplementation((type: NudgeType) => {
return of(type === NudgeType.EmptyVaultNudge); return of(type === NudgeType.EmptyVaultNudge);
}); });

View File

@@ -137,6 +137,10 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
FeatureFlag.VaultLoadingSkeletons, FeatureFlag.VaultLoadingSkeletons,
); );
protected premiumSpotlightFeatureFlag$ = this.configService.getFeatureFlag$(
FeatureFlag.BrowserPremiumSpotlight,
);
private showPremiumNudgeSpotlight$ = this.activeUserId$.pipe( private showPremiumNudgeSpotlight$ = this.activeUserId$.pipe(
switchMap((userId) => this.nudgesService.showNudgeSpotlight$(NudgeType.PremiumUpgrade, userId)), switchMap((userId) => this.nudgesService.showNudgeSpotlight$(NudgeType.PremiumUpgrade, userId)),
); );
@@ -164,6 +168,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
); );
protected showPremiumSpotlight$ = combineLatest([ protected showPremiumSpotlight$ = combineLatest([
this.premiumSpotlightFeatureFlag$,
this.showPremiumNudgeSpotlight$, this.showPremiumNudgeSpotlight$,
this.showHasItemsVaultSpotlight$, this.showHasItemsVaultSpotlight$,
this.hasPremium$, this.hasPremium$,
@@ -171,8 +176,13 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
this.accountAgeInDays$, this.accountAgeInDays$,
]).pipe( ]).pipe(
map( map(
([showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) => ([featureFlagEnabled, showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) =>
showPremiumNudge && !showHasItemsNudge && !hasPremium && count >= 5 && age >= 7, featureFlagEnabled &&
showPremiumNudge &&
!showHasItemsNudge &&
!hasPremium &&
count >= 5 &&
age >= 7,
), ),
shareReplay({ bufferSize: 1, refCount: true }), shareReplay({ bufferSize: 1, refCount: true }),
); );

View File

@@ -0,0 +1,35 @@
# Explainer: Mac OS Native Passkey Provider
This document describes the changes introduced in https://github.com/bitwarden/clients/pull/13963, where we introduce the MacOS Native Passkey Provider. It gives the high level explanation of the architecture and some of the quirks and additional good to know context.
## The high level
MacOS has native APIs (similar to iOS) to allow Credential Managers to provide credentials to the MacOS autofill system (in the PR referenced above, we only provide passkeys).
Weve written a Swift-based native autofill-extension. Its bundled in the app-bundle in PlugIns, similar to the safari-extension.
This swift extension currently communicates with our Electron app through IPC based on a unix socket. The IPC implementation is done in Rust and utilized through UniFFI + NAPI bindings.
Footnotes:
* We're not using the IPC framework as the implementation pre-dates the IPC framework.
* Alternatives like XPC or CFMessagePort may have better support for when the app is sandboxed.
Electron receives the messages and passes it to Angular (through the electron-renderer event system).
Our existing fido2 services in the renderer respond to events, displaying UI as necessary, and returns the signature back through the same mechanism, allowing people to authenticate with passkeys through the native system + UI. See [Mac OS Native Passkey Workflows](https://bitwarden.atlassian.net/wiki/spaces/EN/pages/1828356098/Mac+OS+Native+Passkey+Workflows) for demo videos.
## Typescript + UI implementations
We utilize the same FIDO2 implementation and interface that is already present for our browser authentication. It was designed by @coroiu with multiple ui environments' in mind.
Therefore, a lot of the plumbing is implemented in /autofill/services/desktop-fido2-user-interface.service.ts, which implements the interface that our fido2 authenticator/client expects to drive UI related behaviors.
Weve also implemented a couple FIDO2 UI components to handle registration/sign in flows, but also improved the “modal mode” of the desktop app.
## Modal mode
When modal mode is activated, the desktop app turns into a smaller modal that is always on top and cannot be resized. This is done to improve the UX of performing a passkey operation (or SSH operation). Once the operation is completed, the app returns to normal mode and its previous position.
We are not using electron modal windows, for a couple reason. It would require us to send data in yet another layer of IPC, but also because we'd need to bootstrap entire renderer/app instead of reusing the existing window.
Some modal modes may hide the 'traffic buttons' (window controls) due to design requirements.

View File

@@ -8,6 +8,9 @@ rm -r tmp
mkdir -p ./tmp/target/universal-darwin/release/ mkdir -p ./tmp/target/universal-darwin/release/
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
cargo build --package macos_provider --target aarch64-apple-darwin --release cargo build --package macos_provider --target aarch64-apple-darwin --release
cargo build --package macos_provider --target x86_64-apple-darwin --release cargo build --package macos_provider --target x86_64-apple-darwin --release

View File

@@ -57,6 +57,14 @@ trait Callback: Send + Sync {
fn error(&self, error: BitwardenError); fn error(&self, error: BitwardenError);
} }
#[derive(uniffi::Enum, Debug)]
/// Store the connection status between the macOS credential provider extension
/// and the desktop application's IPC server.
pub enum ConnectionStatus {
Connected,
Disconnected,
}
#[derive(uniffi::Object)] #[derive(uniffi::Object)]
pub struct MacOSProviderClient { pub struct MacOSProviderClient {
to_server_send: tokio::sync::mpsc::Sender<String>, to_server_send: tokio::sync::mpsc::Sender<String>,
@@ -65,8 +73,24 @@ pub struct MacOSProviderClient {
response_callbacks_counter: AtomicU32, response_callbacks_counter: AtomicU32,
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
response_callbacks_queue: Arc<Mutex<HashMap<u32, (Box<dyn Callback>, Instant)>>>, response_callbacks_queue: Arc<Mutex<HashMap<u32, (Box<dyn Callback>, Instant)>>>,
// Flag to track connection status - atomic for thread safety without locks
connection_status: Arc<std::sync::atomic::AtomicBool>,
} }
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
/// Store native desktop status information to use for IPC communication
/// between the application and the macOS credential provider.
pub struct NativeStatus {
key: String,
value: String,
}
// In our callback management, 0 is a reserved sequence number indicating that a message does not
// have a callback.
const NO_CALLBACK_INDICATOR: u32 = 0;
#[uniffi::export] #[uniffi::export]
impl MacOSProviderClient { impl MacOSProviderClient {
// FIXME: Remove unwraps! They panic and terminate the whole application. // FIXME: Remove unwraps! They panic and terminate the whole application.
@@ -93,13 +117,16 @@ impl MacOSProviderClient {
let client = MacOSProviderClient { let client = MacOSProviderClient {
to_server_send, to_server_send,
response_callbacks_counter: AtomicU32::new(0), response_callbacks_counter: AtomicU32::new(1), /* Start at 1 since 0 is reserved for
* "no callback" scenarios */
response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())), response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())),
connection_status: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}; };
let path = desktop_core::ipc::path("af"); let path = desktop_core::ipc::path("af");
let queue = client.response_callbacks_queue.clone(); let queue = client.response_callbacks_queue.clone();
let connection_status = client.connection_status.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
@@ -117,9 +144,11 @@ impl MacOSProviderClient {
match serde_json::from_str::<SerializedMessage>(&message) { match serde_json::from_str::<SerializedMessage>(&message) {
Ok(SerializedMessage::Command(CommandMessage::Connected)) => { Ok(SerializedMessage::Command(CommandMessage::Connected)) => {
info!("Connected to server"); info!("Connected to server");
connection_status.store(true, std::sync::atomic::Ordering::Relaxed);
} }
Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => { Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => {
info!("Disconnected from server"); info!("Disconnected from server");
connection_status.store(false, std::sync::atomic::Ordering::Relaxed);
} }
Ok(SerializedMessage::Message { Ok(SerializedMessage::Message {
sequence_number, sequence_number,
@@ -157,12 +186,17 @@ impl MacOSProviderClient {
client client
} }
pub fn send_native_status(&self, key: String, value: String) {
let status = NativeStatus { key, value };
self.send_message(status, None);
}
pub fn prepare_passkey_registration( pub fn prepare_passkey_registration(
&self, &self,
request: PasskeyRegistrationRequest, request: PasskeyRegistrationRequest,
callback: Arc<dyn PreparePasskeyRegistrationCallback>, callback: Arc<dyn PreparePasskeyRegistrationCallback>,
) { ) {
self.send_message(request, Box::new(callback)); self.send_message(request, Some(Box::new(callback)));
} }
pub fn prepare_passkey_assertion( pub fn prepare_passkey_assertion(
@@ -170,7 +204,7 @@ impl MacOSProviderClient {
request: PasskeyAssertionRequest, request: PasskeyAssertionRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>, callback: Arc<dyn PreparePasskeyAssertionCallback>,
) { ) {
self.send_message(request, Box::new(callback)); self.send_message(request, Some(Box::new(callback)));
} }
pub fn prepare_passkey_assertion_without_user_interface( pub fn prepare_passkey_assertion_without_user_interface(
@@ -178,7 +212,18 @@ impl MacOSProviderClient {
request: PasskeyAssertionWithoutUserInterfaceRequest, request: PasskeyAssertionWithoutUserInterfaceRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>, callback: Arc<dyn PreparePasskeyAssertionCallback>,
) { ) {
self.send_message(request, Box::new(callback)); self.send_message(request, Some(Box::new(callback)));
}
pub fn get_connection_status(&self) -> ConnectionStatus {
let is_connected = self
.connection_status
.load(std::sync::atomic::Ordering::Relaxed);
if is_connected {
ConnectionStatus::Connected
} else {
ConnectionStatus::Disconnected
}
} }
} }
@@ -200,7 +245,6 @@ enum SerializedMessage {
} }
impl MacOSProviderClient { impl MacOSProviderClient {
// FIXME: Remove unwraps! They panic and terminate the whole application.
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
fn add_callback(&self, callback: Box<dyn Callback>) -> u32 { fn add_callback(&self, callback: Box<dyn Callback>) -> u32 {
let sequence_number = self let sequence_number = self
@@ -209,20 +253,23 @@ impl MacOSProviderClient {
self.response_callbacks_queue self.response_callbacks_queue
.lock() .lock()
.unwrap() .expect("response callbacks queue mutex should not be poisoned")
.insert(sequence_number, (callback, Instant::now())); .insert(sequence_number, (callback, Instant::now()));
sequence_number sequence_number
} }
// FIXME: Remove unwraps! They panic and terminate the whole application.
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
fn send_message( fn send_message(
&self, &self,
message: impl Serialize + DeserializeOwned, message: impl Serialize + DeserializeOwned,
callback: Box<dyn Callback>, callback: Option<Box<dyn Callback>>,
) { ) {
let sequence_number = self.add_callback(callback); let sequence_number = if let Some(callback) = callback {
self.add_callback(callback)
} else {
NO_CALLBACK_INDICATOR
};
let message = serde_json::to_string(&SerializedMessage::Message { let message = serde_json::to_string(&SerializedMessage::Message {
sequence_number, sequence_number,
@@ -232,15 +279,17 @@ impl MacOSProviderClient {
if let Err(e) = self.to_server_send.blocking_send(message) { if let Err(e) = self.to_server_send.blocking_send(message) {
// Make sure we remove the callback from the queue if we can't send the message // Make sure we remove the callback from the queue if we can't send the message
if let Some((cb, _)) = self if sequence_number != NO_CALLBACK_INDICATOR {
.response_callbacks_queue if let Some((callback, _)) = self
.lock() .response_callbacks_queue
.unwrap() .lock()
.remove(&sequence_number) .expect("response callbacks queue mutex should not be poisoned")
{ .remove(&sequence_number)
cb.error(BitwardenError::Internal(format!( {
"Error sending message: {e}" callback.error(BitwardenError::Internal(format!(
))); "Error sending message: {e}"
)));
}
} }
} }
} }

View File

@@ -14,6 +14,7 @@ pub struct PasskeyRegistrationRequest {
user_verification: UserVerification, user_verification: UserVerification,
supported_algorithms: Vec<i32>, supported_algorithms: Vec<i32>,
window_xy: Position, window_xy: Position,
excluded_credentials: Vec<Vec<u8>>,
} }
#[derive(uniffi::Record, Serialize, Deserialize)] #[derive(uniffi::Record, Serialize, Deserialize)]

View File

@@ -164,6 +164,7 @@ export declare namespace autofill {
userVerification: UserVerification userVerification: UserVerification
supportedAlgorithms: Array<number> supportedAlgorithms: Array<number>
windowXy: Position windowXy: Position
excludedCredentials: Array<Array<number>>
} }
export interface PasskeyRegistrationResponse { export interface PasskeyRegistrationResponse {
rpId: string rpId: string
@@ -188,6 +189,10 @@ export declare namespace autofill {
userVerification: UserVerification userVerification: UserVerification
windowXy: Position windowXy: Position
} }
export interface NativeStatus {
key: string
value: string
}
export interface PasskeyAssertionResponse { export interface PasskeyAssertionResponse {
rpId: string rpId: string
userHandle: Array<number> userHandle: Array<number>
@@ -204,7 +209,7 @@ export declare namespace autofill {
* connection and must be the same for both the server and client. @param callback * 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. * 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, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => 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, nativeStatusCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void): Promise<IpcServer>
/** Return the path to the IPC server. */ /** Return the path to the IPC server. */
getPath(): string getPath(): string
/** Stop the IPC server. */ /** Stop the IPC server. */

View File

@@ -686,6 +686,7 @@ pub mod autofill {
pub user_verification: UserVerification, pub user_verification: UserVerification,
pub supported_algorithms: Vec<i32>, pub supported_algorithms: Vec<i32>,
pub window_xy: Position, pub window_xy: Position,
pub excluded_credentials: Vec<Vec<u8>>,
} }
#[napi(object)] #[napi(object)]
@@ -724,6 +725,14 @@ pub mod autofill {
pub window_xy: Position, pub window_xy: Position,
} }
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeStatus {
pub key: String,
pub value: String,
}
#[napi(object)] #[napi(object)]
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -777,6 +786,13 @@ pub mod autofill {
(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest), (u32, u32, PasskeyAssertionWithoutUserInterfaceRequest),
ErrorStrategy::CalleeHandled, ErrorStrategy::CalleeHandled,
>, >,
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void"
)]
native_status_callback: ThreadsafeFunction<
(u32, u32, NativeStatus),
ErrorStrategy::CalleeHandled,
>,
) -> napi::Result<Self> { ) -> napi::Result<Self> {
let (send, mut recv) = tokio::sync::mpsc::channel::<Message>(32); let (send, mut recv) = tokio::sync::mpsc::channel::<Message>(32);
tokio::spawn(async move { tokio::spawn(async move {
@@ -849,6 +865,21 @@ pub mod autofill {
} }
} }
match serde_json::from_str::<PasskeyMessage<NativeStatus>>(&message) {
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value))
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
native_status_callback
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
continue;
}
Err(error) => {
error!(%error, "Unable to deserialze native status.");
}
}
error!(message, "Received an unknown message2"); error!(message, "Received an unknown message2");
} }
} }

View File

@@ -14,40 +14,64 @@ void runSync(void* context, NSDictionary *params) {
// Map credentials to ASPasswordCredential objects // Map credentials to ASPasswordCredential objects
NSMutableArray *mappedCredentials = [NSMutableArray arrayWithCapacity:credentials.count]; NSMutableArray *mappedCredentials = [NSMutableArray arrayWithCapacity:credentials.count];
for (NSDictionary *credential in credentials) { for (NSDictionary *credential in credentials) {
NSString *type = credential[@"type"]; @try {
NSString *type = credential[@"type"];
if ([type isEqualToString:@"password"]) {
NSString *cipherId = credential[@"cipherId"]; if ([type isEqualToString:@"password"]) {
NSString *uri = credential[@"uri"];
NSString *username = credential[@"username"];
ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc]
initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL];
ASPasswordCredentialIdentity *credential = [[ASPasswordCredentialIdentity alloc]
initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId];
[mappedCredentials addObject:credential];
}
if (@available(macos 14, *)) {
if ([type isEqualToString:@"fido2"]) {
NSString *cipherId = credential[@"cipherId"]; NSString *cipherId = credential[@"cipherId"];
NSString *rpId = credential[@"rpId"]; NSString *uri = credential[@"uri"];
NSString *userName = credential[@"userName"]; NSString *username = credential[@"username"];
NSData *credentialId = decodeBase64URL(credential[@"credentialId"]);
NSData *userHandle = decodeBase64URL(credential[@"userHandle"]); // Skip credentials with null username since MacOS crashes if we send credentials with empty usernames
if ([username isKindOfClass:[NSNull class]] || username.length == 0) {
NSLog(@"Skipping credential, username is empty: %@", credential);
continue;
}
Class passkeyCredentialIdentityClass = NSClassFromString(@"ASPasskeyCredentialIdentity"); ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc]
id credential = [[passkeyCredentialIdentityClass alloc] initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL];
initWithRelyingPartyIdentifier:rpId ASPasswordCredentialIdentity *passwordIdentity = [[ASPasswordCredentialIdentity alloc]
userName:userName initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId];
credentialID:credentialId
userHandle:userHandle
recordIdentifier:cipherId];
[mappedCredentials addObject:credential]; [mappedCredentials addObject:passwordIdentity];
}
else if (@available(macos 14, *)) {
// Fido2CredentialView uses `userName` (camelCase) while Login uses `username`.
// This is intentional. Fido2 fields are flattened from the FIDO2 spec's nested structure
// (user.name -> userName, rp.id -> rpId) to maintain a clear distinction between these fields.
if ([type isEqualToString:@"fido2"]) {
NSString *cipherId = credential[@"cipherId"];
NSString *rpId = credential[@"rpId"];
NSString *userName = credential[@"userName"];
// Skip credentials with null username since MacOS crashes if we send credentials with empty usernames
if ([userName isKindOfClass:[NSNull class]] || userName.length == 0) {
NSLog(@"Skipping credential, username is empty: %@", credential);
continue;
}
NSData *credentialId = decodeBase64URL(credential[@"credentialId"]);
NSData *userHandle = decodeBase64URL(credential[@"userHandle"]);
Class passkeyCredentialIdentityClass = NSClassFromString(@"ASPasskeyCredentialIdentity");
id passkeyIdentity = [[passkeyCredentialIdentityClass alloc]
initWithRelyingPartyIdentifier:rpId
userName:userName
credentialID:credentialId
userHandle:userHandle
recordIdentifier:cipherId];
[mappedCredentials addObject:passkeyIdentity];
}
} }
} @catch (NSException *exception) {
// Silently skip any credential that causes an exception
// to make sure we don't fail the entire sync
// There is likely some invalid data in the credential, and not something the user should/could be asked to correct.
NSLog(@"ERROR: Exception processing credential: %@ - %@", exception.name, exception.reason);
continue;
} }
} }

View File

@@ -18,9 +18,26 @@ NSString *serializeJson(NSDictionary *dictionary, NSError *error) {
} }
NSData *decodeBase64URL(NSString *base64URLString) { NSData *decodeBase64URL(NSString *base64URLString) {
if (base64URLString.length == 0) {
return nil;
}
// Replace URL-safe characters with standard base64 characters
NSString *base64String = [base64URLString stringByReplacingOccurrencesOfString:@"-" withString:@"+"]; NSString *base64String = [base64URLString stringByReplacingOccurrencesOfString:@"-" withString:@"+"];
base64String = [base64String stringByReplacingOccurrencesOfString:@"_" withString:@"/"]; base64String = [base64String stringByReplacingOccurrencesOfString:@"_" withString:@"/"];
// Add padding if needed
// Base 64 strings should be a multiple of 4 in length
NSUInteger paddingLength = 4 - (base64String.length % 4);
if (paddingLength < 4) {
NSMutableString *paddedString = [NSMutableString stringWithString:base64String];
for (NSUInteger i = 0; i < paddingLength; i++) {
[paddedString appendString:@"="];
}
base64String = paddedString;
}
// Decode the string
NSData *nsdataFromBase64String = [[NSData alloc] NSData *nsdataFromBase64String = [[NSData alloc]
initWithBase64EncodedString:base64String options:0]; initWithBase64EncodedString:base64String options:0];

View File

@@ -8,63 +8,56 @@
<objects> <objects>
<customObject id="-2" userLabel="File's Owner" customClass="CredentialProviderViewController" customModule="autofill_extension" customModuleProvider="target"> <customObject id="-2" userLabel="File's Owner" customClass="CredentialProviderViewController" customModule="autofill_extension" customModuleProvider="target">
<connections> <connections>
<outlet property="logoImageView" destination="logoImageView" id="logoImageViewOutlet"/>
<outlet property="statusLabel" destination="statusLabel" id="statusLabelOutlet"/>
<outlet property="view" destination="1" id="2"/> <outlet property="view" destination="1" id="2"/>
</connections> </connections>
</customObject> </customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/> <customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/> <customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customView hidden="YES" translatesAutoresizingMaskIntoConstraints="NO" id="1"> <customView hidden="YES" translatesAutoresizingMaskIntoConstraints="NO" id="1">
<rect key="frame" x="0.0" y="0.0" width="378" height="94"/> <rect key="frame" x="0.0" y="0.0" width="400" height="120"/>
<subviews> <subviews>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="1uM-r7-H1c"> <stackView distribution="fill" orientation="horizontal" alignment="centerY" spacing="20" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" translatesAutoresizingMaskIntoConstraints="NO" id="configStackView">
<rect key="frame" x="184" y="3" width="191" height="32"/> <rect key="frame" x="89" y="35" width="223" height="50"/>
<buttonCell key="cell" type="push" title="Return Example Password" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="2l4-PO-we5"> <subviews>
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/> <imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="logoImageView">
<font key="font" metaFont="system"/> <rect key="frame" x="0.0" y="0.0" width="50" height="50"/>
<string key="keyEquivalent">D</string> <constraints>
<modifierMask key="keyEquivalentModifierMask" command="YES"/> <constraint firstAttribute="height" constant="50" id="logoImageHeight"/>
</buttonCell> <constraint firstAttribute="width" constant="50" id="logoImageWidth"/>
<connections> </constraints>
<action selector="passwordSelected:" target="-2" id="yic-EC-GGk"/> <imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyUpOrDown" image="bitwarden-icon" id="logoImageCell"/>
</connections> </imageView>
</button> <textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="statusLabel">
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="NVE-vN-dkz"> <rect key="frame" x="68" y="16" width="157" height="19"/>
<rect key="frame" x="114" y="3" width="76" height="32"/> <textFieldCell key="cell" sendsActionOnEndEditing="YES" alignment="left" title="Enabling Bitwarden..." id="statusLabelCell">
<buttonCell key="cell" type="push" title="Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="6Up-t3-mwm"> <font key="font" metaFont="system" size="16"/>
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/> <color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<font key="font" metaFont="system"/> <color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
<string key="keyEquivalent" base64-UTF8="YES"> </textFieldCell>
Gw </textField>
</string> </subviews>
</buttonCell> <visibilityPriorities>
<constraints> <integer value="1000"/>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="60" id="cP1-hK-9ZX"/> <integer value="1000"/>
</constraints> </visibilityPriorities>
<connections> <customSpacing>
<action selector="cancel:" target="-2" id="Qav-AK-DGt"/> <real value="3.4028234663852886e+38"/>
</connections> <real value="3.4028234663852886e+38"/>
</button> </customSpacing>
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="aNc-0i-CWK"> </stackView>
<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"/>
</textFieldCell>
</textField>
</subviews> </subviews>
<constraints> <constraints>
<constraint firstItem="1uM-r7-H1c" firstAttribute="leading" secondItem="NVE-vN-dkz" secondAttribute="trailing" constant="8" id="1UO-J1-LbJ"/> <constraint firstItem="configStackView" firstAttribute="centerX" secondItem="1" secondAttribute="centerX" id="stackCenterX"/>
<constraint firstItem="NVE-vN-dkz" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="1" secondAttribute="leading" constant="20" symbolic="YES" id="3N9-qo-UfS"/> <constraint firstItem="configStackView" firstAttribute="centerY" secondItem="1" secondAttribute="centerY" id="stackCenterY"/>
<constraint firstAttribute="bottom" secondItem="1uM-r7-H1c" secondAttribute="bottom" constant="10" id="4wH-De-nMF"/> <constraint firstItem="configStackView" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="1" secondAttribute="leading" constant="20" id="stackLeading"/>
<constraint firstItem="NVE-vN-dkz" firstAttribute="firstBaseline" secondItem="aNc-0i-CWK" secondAttribute="baseline" constant="50" id="Dpq-cK-cPE"/> <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="configStackView" secondAttribute="trailing" constant="20" id="stackTrailing"/>
<constraint firstAttribute="bottom" secondItem="NVE-vN-dkz" secondAttribute="bottom" constant="10" id="USG-Gg-of3"/>
<constraint firstItem="1uM-r7-H1c" firstAttribute="leading" secondItem="NVE-vN-dkz" secondAttribute="trailing" constant="8" id="a8N-vS-Ew9"/>
<constraint firstAttribute="trailing" secondItem="1uM-r7-H1c" secondAttribute="trailing" constant="10" id="qfT-cw-QQ2"/>
<constraint firstAttribute="centerX" secondItem="aNc-0i-CWK" secondAttribute="centerX" id="uV3-Wn-RA3"/>
<constraint firstItem="aNc-0i-CWK" firstAttribute="top" secondItem="1" secondAttribute="top" constant="15" id="vpR-tf-ebx"/>
</constraints> </constraints>
<point key="canvasLocation" x="162" y="146"/> <point key="canvasLocation" x="200" y="60"/>
</customView> </customView>
</objects> </objects>
<resources>
<image name="bitwarden-icon" width="64" height="64"/>
</resources>
</document> </document>

View File

@@ -11,63 +11,138 @@ import os
class CredentialProviderViewController: ASCredentialProviderViewController { class CredentialProviderViewController: ASCredentialProviderViewController {
let logger: Logger let logger: Logger
// There is something a bit strange about the initialization/deinitialization in this class. @IBOutlet weak var statusLabel: NSTextField!
// Sometimes deinit won't be called after a request has successfully finished, @IBOutlet weak var logoImageView: NSImageView!
// which would leave this class hanging in memory and the IPC connection open.
// // The IPC client to communicate with the Bitwarden desktop app
// If instead I make this a static, the deinit gets called correctly after each request. private var client: MacOsProviderClient?
// I think we still might want a static regardless, to be able to reuse the connection if possible.
let client: MacOsProviderClient = { // Timer for checking connection status
let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider") private var connectionMonitorTimer: Timer?
private var lastConnectionStatus: ConnectionStatus = .disconnected
// We changed the getClient method to be async, here's why:
// This is so that we can check if the app is running, and launch it, without blocking the main thread
// Blocking the main thread caused MacOS layouting to 'fail' or at least be very delayed, which caused our getWindowPositioning code to sent 0,0.
// We also properly retry the IPC connection which sometimes would take some time to be up and running, depending on CPU load, phase of jupiters moon, etc.
private func getClient() async -> MacOsProviderClient {
if let client = self.client {
return client
}
let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
// Check if the Electron app is running // Check if the Electron app is running
let workspace = NSWorkspace.shared let workspace = NSWorkspace.shared
let isRunning = workspace.runningApplications.contains { app in let isRunning = workspace.runningApplications.contains { app in
app.bundleIdentifier == "com.bitwarden.desktop" app.bundleIdentifier == "com.bitwarden.desktop"
} }
if !isRunning { if !isRunning {
logger.log("[autofill-extension] Bitwarden Desktop not running, attempting to launch") logger.log("[autofill-extension] Bitwarden Desktop not running, attempting to launch")
// Try to launch the app // Launch the app and wait for it to be ready
if let appURL = workspace.urlForApplication(withBundleIdentifier: "com.bitwarden.desktop") { if let appURL = workspace.urlForApplication(withBundleIdentifier: "com.bitwarden.desktop") {
let semaphore = DispatchSemaphore(value: 0) await withCheckedContinuation { continuation in
workspace.openApplication(at: appURL, configuration: NSWorkspace.OpenConfiguration()) { app, error in
workspace.openApplication(at: appURL, if let error = error {
configuration: NSWorkspace.OpenConfiguration()) { app, error in logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: \(error.localizedDescription)")
if let error = error { } else {
logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: \(error.localizedDescription)") logger.log("[autofill-extension] Successfully launched Bitwarden Desktop")
} else if let app = app { }
logger.log("[autofill-extension] Successfully launched Bitwarden Desktop") continuation.resume()
} 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")
// Retry connecting to the Bitwarden IPC with an increasing delay
let maxRetries = 20
let delayMs = 500
var newClient: MacOsProviderClient?
for attempt in 1...maxRetries {
logger.log("[autofill-extension] Connection attempt \(attempt)")
// Create a new client instance for each retry
newClient = MacOsProviderClient.connect()
try? await Task.sleep(nanoseconds: UInt64(100 * attempt + (delayMs * 1_000_000))) // Convert ms to nanoseconds
let connectionStatus = newClient!.getConnectionStatus()
logger.log("[autofill-extension] Connection attempt \(attempt), status: \(connectionStatus == .connected ? "connected" : "disconnected")")
if connectionStatus == .connected {
logger.log("[autofill-extension] Successfully connected to Bitwarden (attempt \(attempt))")
break
} else {
if attempt < maxRetries {
logger.log("[autofill-extension] Retrying connection")
} else {
logger.error("[autofill-extension] Failed to connect after \(maxRetries) attempts, final status: \(connectionStatus == .connected ? "connected" : "disconnected")")
}
}
} }
logger.log("[autofill-extension] Connecting to Bitwarden over IPC") self.client = newClient
return newClient!
return MacOsProviderClient.connect() }
}()
// Setup the connection monitoring timer
private func setupConnectionMonitoring() {
// Check connection status every 1 second
connectionMonitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.checkConnectionStatus()
}
// Make sure timer runs even when UI is busy
RunLoop.current.add(connectionMonitorTimer!, forMode: .common)
// Initial check
checkConnectionStatus()
}
// Check the connection status by calling into Rust
// If the connection is has changed and is now disconnected, cancel the request
private func checkConnectionStatus() {
// Only check connection status if the client has been initialized.
// Initialization is done asynchronously, so we might be called before it's ready
// In that case we just skip this check and wait for the next timer tick and re-check
guard let client = self.client else {
return
}
// Get the current connection status from Rust
let currentStatus = client.getConnectionStatus()
// Only post notification if state changed
if currentStatus != lastConnectionStatus {
if(currentStatus == .connected) {
logger.log("[autofill-extension] Connection status changed: Connected")
} else {
logger.log("[autofill-extension] Connection status changed: Disconnected")
}
// Save the new status
lastConnectionStatus = currentStatus
// If we just disconnected, try to cancel the request
if currentStatus == .disconnected {
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Bitwarden desktop app disconnected"))
}
}
}
init() { init() {
logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider") logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
logger.log("[autofill-extension] initializing extension") logger.log("[autofill-extension] initializing extension")
super.init(nibName: nil, bundle: nil) super.init(nibName: "CredentialProviderViewController", bundle: nil)
// Setup connection monitoring now that self is available
setupConnectionMonitoring()
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@@ -76,45 +151,109 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
deinit { deinit {
logger.log("[autofill-extension] deinitializing extension") logger.log("[autofill-extension] deinitializing extension")
}
@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)
}
private func getWindowPosition() -> Position {
let frame = self.view.window?.frame ?? .zero
let screenHeight = NSScreen.main?.frame.height ?? 0
// frame.width and frame.height is always 0. Estimating works OK for now. // Stop the connection monitor timer
let estimatedWidth:CGFloat = 400; connectionMonitorTimer?.invalidate()
let estimatedHeight:CGFloat = 200; connectionMonitorTimer = nil
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() { private func getWindowPosition() async -> Position {
let view = NSView() let screenHeight = NSScreen.main?.frame.height ?? 1440
// Hide the native window since we only need the IPC connection
view.isHidden = true logger.log("[autofill-extension] position: Getting window position")
self.view = view
// To whomever is reading this. Sorry. But MacOS couldn't give us an accurate window positioning, possibly due to animations
// So I added some retry logic, as well as a fall back to the mouse position which is likely at the sort of the right place.
// In my testing we often succed after 4-7 attempts.
// Wait for window frame to stabilize (animation to complete)
var lastFrame: CGRect = .zero
var stableCount = 0
let requiredStableChecks = 3
let maxAttempts = 20
var attempts = 0
while stableCount < requiredStableChecks && attempts < maxAttempts {
let currentFrame: CGRect = self.view.window?.frame ?? .zero
if currentFrame.equalTo(lastFrame) && !currentFrame.equalTo(.zero) {
stableCount += 1
} else {
stableCount = 0
lastFrame = currentFrame
}
try? await Task.sleep(nanoseconds: 16_666_666) // ~60fps (16.67ms)
attempts += 1
}
let finalWindowFrame = self.view.window?.frame ?? .zero
logger.log("[autofill-extension] position: Final window frame: \(NSStringFromRect(finalWindowFrame))")
// Use stabilized window frame if available, otherwise fallback to mouse position
if finalWindowFrame.origin.x != 0 || finalWindowFrame.origin.y != 0 {
let centerX = Int32(round(finalWindowFrame.origin.x))
let centerY = Int32(round(screenHeight - finalWindowFrame.origin.y))
logger.log("[autofill-extension] position: Using window position: x=\(centerX), y=\(centerY)")
return Position(x: centerX, y: centerY)
} else {
// Fallback to mouse position
let mouseLocation = NSEvent.mouseLocation
let mouseX = Int32(round(mouseLocation.x))
let mouseY = Int32(round(screenHeight - mouseLocation.y))
logger.log("[autofill-extension] position: Using mouse position fallback: x=\(mouseX), y=\(mouseY)")
return Position(x: mouseX, y: mouseY)
}
} }
override func viewDidLoad() {
super.viewDidLoad()
// Initially hide the view
self.view.isHidden = true
}
override func prepareInterfaceForExtensionConfiguration() {
// Show the configuration UI
self.view.isHidden = false
// Set the localized message
statusLabel.stringValue = NSLocalizedString("autofillConfigurationMessage", comment: "Message shown when Bitwarden is enabled in system settings")
// Send the native status request asynchronously
Task {
let client = await getClient()
client.sendNativeStatus(key: "request-sync", value: "")
}
// Complete the configuration after 2 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
self?.extensionContext.completeExtensionConfigurationRequest()
}
}
/*
In order to implement this method, we need to query the state of the vault to be unlocked and have one and only one matching credential so that it doesn't need to show ui.
If we do show UI, it's going to fail and disconnect after the platform timeout which is 3s.
For now we just claim to always need UI displayed.
*/
override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) { override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) {
let error = ASExtensionError(.userInteractionRequired)
self.extensionContext.cancelRequest(withError: error)
return
}
/*
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 credentialRequest: ASCredentialRequest) {
let timeoutTimer = createTimer() let timeoutTimer = createTimer()
if let request = credentialRequest as? ASPasskeyCredentialRequest { if let request = credentialRequest as? ASPasskeyCredentialRequest {
if let passkeyIdentity = request.credentialIdentity as? ASPasskeyCredentialIdentity { if let passkeyIdentity = request.credentialIdentity as? ASPasskeyCredentialIdentity {
logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2(passkey) called \(request)") logger.log("[autofill-extension] prepareInterfaceToProvideCredential (passkey) called \(request)")
class CallbackImpl: PreparePasskeyAssertionCallback { class CallbackImpl: PreparePasskeyAssertionCallback {
let ctx: ASCredentialProviderExtensionContext let ctx: ASCredentialProviderExtensionContext
@@ -154,18 +293,25 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
UserVerification.discouraged UserVerification.discouraged
} }
let req = PasskeyAssertionWithoutUserInterfaceRequest( /*
rpId: passkeyIdentity.relyingPartyIdentifier, We're still using the old request type here, because we're sending the same data, we're expecting a single credential to be used
credentialId: passkeyIdentity.credentialID, */
userName: passkeyIdentity.userName, Task {
userHandle: passkeyIdentity.userHandle, let windowPosition = await self.getWindowPosition()
recordIdentifier: passkeyIdentity.recordIdentifier, let req = PasskeyAssertionWithoutUserInterfaceRequest(
clientDataHash: request.clientDataHash, rpId: passkeyIdentity.relyingPartyIdentifier,
userVerification: userVerification, credentialId: passkeyIdentity.credentialID,
windowXy: self.getWindowPosition() userName: passkeyIdentity.userName,
) userHandle: passkeyIdentity.userHandle,
recordIdentifier: passkeyIdentity.recordIdentifier,
self.client.preparePasskeyAssertionWithoutUserInterface(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) clientDataHash: request.clientDataHash,
userVerification: userVerification,
windowXy: windowPosition
)
let client = await getClient()
client.preparePasskeyAssertionWithoutUserInterface(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
}
return return
} }
} }
@@ -176,16 +322,6 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Invalid authentication request")) 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) {
}
*/
private func createTimer() -> DispatchWorkItem { private func createTimer() -> DispatchWorkItem {
// Create a timer for 600 second timeout // Create a timer for 600 second timeout
let timeoutTimer = DispatchWorkItem { [weak self] in let timeoutTimer = DispatchWorkItem { [weak self] in
@@ -246,18 +382,32 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
UserVerification.discouraged UserVerification.discouraged
} }
let req = PasskeyRegistrationRequest( // Convert excluded credentials to an array of credential IDs
rpId: passkeyIdentity.relyingPartyIdentifier, var excludedCredentialIds: [Data] = []
userName: passkeyIdentity.userName, if #available(macOSApplicationExtension 15.0, *) {
userHandle: passkeyIdentity.userHandle, if let excludedCreds = request.excludedCredentials {
clientDataHash: request.clientDataHash, excludedCredentialIds = excludedCreds.map { $0.credentialID }
userVerification: userVerification, }
supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) }, }
windowXy: self.getWindowPosition()
)
logger.log("[autofill-extension] prepareInterface(passkey) calling preparePasskeyRegistration") logger.log("[autofill-extension] prepareInterface(passkey) calling preparePasskeyRegistration")
self.client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) Task {
let windowPosition = await self.getWindowPosition()
let req = PasskeyRegistrationRequest(
rpId: passkeyIdentity.relyingPartyIdentifier,
userName: passkeyIdentity.userName,
userHandle: passkeyIdentity.userHandle,
clientDataHash: request.clientDataHash,
userVerification: userVerification,
supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) },
windowXy: windowPosition,
excludedCredentials: excludedCredentialIds
)
let client = await getClient()
client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
}
return return
} }
} }
@@ -310,18 +460,22 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
UserVerification.discouraged 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() let timeoutTimer = createTimer()
self.client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) Task {
let windowPosition = await self.getWindowPosition()
let req = PasskeyAssertionRequest(
rpId: requestParameters.relyingPartyIdentifier,
clientDataHash: requestParameters.clientDataHash,
userVerification: userVerification,
allowedCredentials: requestParameters.allowedCredentials,
windowXy: windowPosition
//extensionInput: requestParameters.extensionInput, // We don't support extensions yet
)
let client = await getClient()
client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
}
return return
} }
} }

View File

@@ -10,9 +10,9 @@
<dict> <dict>
<key>ProvidesPasskeys</key> <key>ProvidesPasskeys</key>
<true/> <true/>
<key>ShowsConfigurationUI</key>
<true/>
</dict> </dict>
<key>ASCredentialProviderExtensionShowsConfigurationUI</key>
<false/>
</dict> </dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>
<string>com.apple.authentication-services-credential-provider-ui</string> <string>com.apple.authentication-services-credential-provider-ui</string>

View File

@@ -2,11 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.application-groups</key>
<true/>
<key>com.apple.security.application-groups</key>
<array> <array>
<string>LTZ2PFU5D6.com.bitwarden.desktop</string> <string>LTZ2PFU5D6.com.bitwarden.desktop</string>
</array> </array>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,2 @@
/* Message shown during passkey configuration */
"autofillConfigurationMessage" = "Enabling Bitwarden...";

View File

@@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
3368DB392C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */; }; 3368DB392C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */; };
3368DB3B2C654F3800896B75 /* BitwardenMacosProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */; }; 3368DB3B2C654F3800896B75 /* BitwardenMacosProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */; };
9AE299092DF9D82E00AAE454 /* bitwarden-icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */; };
9AE299122DFB57A200AAE454 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 9AE2990D2DFB57A200AAE454 /* Localizable.strings */; };
E1DF713F2B342F6900F29026 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1DF713E2B342F6900F29026 /* AuthenticationServices.framework */; }; E1DF713F2B342F6900F29026 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1DF713E2B342F6900F29026 /* AuthenticationServices.framework */; };
E1DF71422B342F6900F29026 /* CredentialProviderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */; }; E1DF71422B342F6900F29026 /* CredentialProviderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */; };
E1DF71452B342F6900F29026 /* CredentialProviderViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */; }; E1DF71452B342F6900F29026 /* CredentialProviderViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */; };
@@ -18,6 +20,8 @@
3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/macos_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = "<group>"; }; 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/macos_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = "<group>"; };
3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BitwardenMacosProvider.swift; sourceTree = "<group>"; }; 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BitwardenMacosProvider.swift; sourceTree = "<group>"; };
968ED08A2C52A47200FFFEE6 /* ReleaseAppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseAppStore.xcconfig; sourceTree = "<group>"; }; 968ED08A2C52A47200FFFEE6 /* ReleaseAppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseAppStore.xcconfig; sourceTree = "<group>"; };
9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "bitwarden-icon.png"; sourceTree = "<group>"; };
9AE2990C2DFB57A200AAE454 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = "<group>"; };
D83832AB2D67B9AE003FB9F8 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; }; D83832AB2D67B9AE003FB9F8 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
D83832AD2D67B9D0003FB9F8 /* ReleaseDeveloper.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseDeveloper.xcconfig; sourceTree = "<group>"; }; D83832AD2D67B9D0003FB9F8 /* ReleaseDeveloper.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseDeveloper.xcconfig; sourceTree = "<group>"; };
E1DF713C2B342F6900F29026 /* autofill-extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "autofill-extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; E1DF713C2B342F6900F29026 /* autofill-extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "autofill-extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -41,6 +45,14 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
9AE2990E2DFB57A200AAE454 /* en.lproj */ = {
isa = PBXGroup;
children = (
9AE2990D2DFB57A200AAE454 /* Localizable.strings */,
);
path = en.lproj;
sourceTree = "<group>";
};
E1DF711D2B342E2800F29026 = { E1DF711D2B342E2800F29026 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -73,6 +85,8 @@
E1DF71402B342F6900F29026 /* autofill-extension */ = { E1DF71402B342F6900F29026 /* autofill-extension */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
9AE2990E2DFB57A200AAE454 /* en.lproj */,
9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */,
3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */, 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */,
E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */, E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */,
E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */, E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */,
@@ -124,6 +138,7 @@
knownRegions = ( knownRegions = (
en, en,
Base, Base,
sv,
); );
mainGroup = E1DF711D2B342E2800F29026; mainGroup = E1DF711D2B342E2800F29026;
productRefGroup = E1DF71272B342E2800F29026 /* Products */; productRefGroup = E1DF71272B342E2800F29026 /* Products */;
@@ -141,6 +156,8 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E1DF71452B342F6900F29026 /* CredentialProviderViewController.xib in Resources */, E1DF71452B342F6900F29026 /* CredentialProviderViewController.xib in Resources */,
9AE299122DFB57A200AAE454 /* Localizable.strings in Resources */,
9AE299092DF9D82E00AAE454 /* bitwarden-icon.png in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -159,6 +176,14 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */ /* Begin PBXVariantGroup section */
9AE2990D2DFB57A200AAE454 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
9AE2990C2DFB57A200AAE454 /* en */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */ = { E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */ = {
isa = PBXVariantGroup; isa = PBXVariantGroup;
children = ( children = (

View File

@@ -18,6 +18,7 @@
"scripts": { "scripts": {
"postinstall": "electron-rebuild", "postinstall": "electron-rebuild",
"start": "cross-env ELECTRON_IS_DEV=0 ELECTRON_NO_UPDATER=1 electron ./build", "start": "cross-env ELECTRON_IS_DEV=0 ELECTRON_NO_UPDATER=1 electron ./build",
"build-native-macos": "cd desktop_native && ./macos_provider/build.sh && node build.js cross-platform",
"build-native": "cd desktop_native && node build.js", "build-native": "cd desktop_native && node build.js",
"build": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\" \"npm run build:preload\"", "build": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\" \"npm run build:preload\"",
"build:dev": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main:dev\" \"npm run build:renderer:dev\" \"npm run build:preload:dev\"", "build:dev": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main:dev\" \"npm run build:renderer:dev\" \"npm run build:preload:dev\"",
@@ -44,10 +45,9 @@
"pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never", "pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never",
"pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never", "pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never",
"pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never", "pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never",
"pack:mac:mas": "npm run clean:dist && electron-builder --mac mas --universal -p never", "pack:mac:mas": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never",
"pack:mac:mas:with-extension": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never", "pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never",
"pack:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never", "pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"",
"pack:mac:masdev:with-extension": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never",
"pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"",
"pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"",
"pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", "pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never",
@@ -55,11 +55,8 @@
"dist:lin": "npm run build && npm run pack:lin", "dist:lin": "npm run build && npm run pack:lin",
"dist:lin:arm64": "npm run build && npm run pack:lin:arm64", "dist:lin:arm64": "npm run build && npm run pack:lin:arm64",
"dist:mac": "npm run build && npm run pack:mac", "dist:mac": "npm run build && npm run pack:mac",
"dist:mac:with-extension": "npm run build && npm run pack:mac:with-extension",
"dist:mac:mas": "npm run build && npm run pack:mac:mas", "dist:mac:mas": "npm run build && npm run pack:mac:mas",
"dist:mac:mas:with-extension": "npm run build && npm run pack:mac:mas:with-extension", "dist:mac:masdev": "npm run build && npm run pack:mac:masdev",
"dist:mac:masdev": "npm run build:dev && npm run pack:mac:masdev",
"dist:mac:masdev:with-extension": "npm run build:dev && npm run pack:mac:masdev:with-extension",
"dist:win": "npm run build && npm run pack:win", "dist:win": "npm run build && npm run pack:win",
"dist:win:ci": "npm run build && npm run pack:win:ci", "dist:win:ci": "npm run build && npm run pack:win:ci",
"publish:lin": "npm run build && npm run clean:dist && electron-builder --linux --x64 -p always", "publish:lin": "npm run build && npm run clean:dist && electron-builder --linux --x64 -p always",

View File

@@ -6,8 +6,6 @@
<string>LTZ2PFU5D6.com.bitwarden.desktop</string> <string>LTZ2PFU5D6.com.bitwarden.desktop</string>
<key>com.apple.developer.team-identifier</key> <key>com.apple.developer.team-identifier</key>
<string>LTZ2PFU5D6</string> <string>LTZ2PFU5D6</string>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
<key>com.apple.security.cs.allow-jit</key> <key>com.apple.security.cs.allow-jit</key>
<true/> <true/>
</dict> </dict>

View File

@@ -4,9 +4,9 @@
<dict> <dict>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.inherit</key>
<true/>
<key>com.apple.security.cs.allow-jit</key> <key>com.apple.security.cs.allow-jit</key>
<true/> <true/>
<key>com.apple.security.inherit</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -6,19 +6,19 @@
<string>LTZ2PFU5D6.com.bitwarden.desktop</string> <string>LTZ2PFU5D6.com.bitwarden.desktop</string>
<key>com.apple.developer.team-identifier</key> <key>com.apple.developer.team-identifier</key>
<string>LTZ2PFU5D6</string> <string>LTZ2PFU5D6</string>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>LTZ2PFU5D6.com.bitwarden.desktop</string> <string>LTZ2PFU5D6.com.bitwarden.desktop</string>
</array> </array>
<key>com.apple.security.network.client</key> <key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/> <true/>
<key>com.apple.security.files.user-selected.read-write</key> <key>com.apple.security.files.user-selected.read-write</key>
<true/> <true/>
<key>com.apple.security.device.usb</key> <key>com.apple.security.network.client</key>
<true/> <true/>
<key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key> <key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key>
<array> <array>
@@ -36,7 +36,5 @@
<string>/Library/Application Support/Zen/NativeMessagingHosts/</string> <string>/Library/Application Support/Zen/NativeMessagingHosts/</string>
<string>/Library/Application Support/net.imput.helium</string> <string>/Library/Application Support/net.imput.helium</string>
</array> </array>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -16,7 +16,7 @@ async function run(context) {
const appPath = `${context.appOutDir}/${appName}.app`; const appPath = `${context.appOutDir}/${appName}.app`;
const macBuild = context.electronPlatformName === "darwin"; const macBuild = context.electronPlatformName === "darwin";
const copySafariExtension = ["darwin", "mas"].includes(context.electronPlatformName); const copySafariExtension = ["darwin", "mas"].includes(context.electronPlatformName);
const copyAutofillExtension = ["darwin", "mas"].includes(context.electronPlatformName); const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName); // Disabled for mas builds
let shouldResign = false; let shouldResign = false;

View File

@@ -45,11 +45,14 @@ import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/co
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
import { reactiveUnlockVaultGuard } from "../autofill/guards/reactive-vault-guard";
import { Fido2CreateComponent } from "../autofill/modal/credentials/fido2-create.component";
import { Fido2ExcludedCiphersComponent } from "../autofill/modal/credentials/fido2-excluded-ciphers.component";
import { Fido2VaultComponent } from "../autofill/modal/credentials/fido2-vault.component";
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import { VaultV2Component } from "../vault/app/vault/vault-v2.component"; import { VaultV2Component } from "../vault/app/vault/vault-v2.component";
import { VaultComponent } from "../vault/app/vault-v3/vault.component"; import { VaultComponent } from "../vault/app/vault-v3/vault.component";
import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
import { DesktopLayoutComponent } from "./layout/desktop-layout.component"; import { DesktopLayoutComponent } from "./layout/desktop-layout.component";
import { SendComponent } from "./tools/send/send.component"; import { SendComponent } from "./tools/send/send.component";
import { SendV2Component } from "./tools/send-v2/send-v2.component"; import { SendV2Component } from "./tools/send-v2/send-v2.component";
@@ -120,12 +123,16 @@ const routes: Routes = [
canActivate: [authGuard], canActivate: [authGuard],
}, },
{ {
path: "passkeys", path: "fido2-assertion",
component: Fido2PlaceholderComponent, component: Fido2VaultComponent,
}, },
{ {
path: "passkeys", path: "fido2-creation",
component: Fido2PlaceholderComponent, component: Fido2CreateComponent,
},
{
path: "fido2-excluded",
component: Fido2ExcludedCiphersComponent,
}, },
{ {
path: "", path: "",
@@ -271,7 +278,7 @@ const routes: Routes = [
}, },
{ {
path: "lock", path: "lock",
canActivate: [lockGuard()], canActivate: [lockGuard(), reactiveUnlockVaultGuard],
data: { data: {
pageIcon: LockIcon, pageIcon: LockIcon,
pageTitle: { pageTitle: {

View File

@@ -104,7 +104,7 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours
<ng-template #exportVault></ng-template> <ng-template #exportVault></ng-template>
<ng-template #appGenerator></ng-template> <ng-template #appGenerator></ng-template>
<ng-template #loginApproval></ng-template> <ng-template #loginApproval></ng-template>
<app-header></app-header> <app-header *ngIf="showHeader$ | async"></app-header>
<div id="container"> <div id="container">
<div class="loading" *ngIf="loading"> <div class="loading" *ngIf="loading">
@@ -141,6 +141,7 @@ export class AppComponent implements OnInit, OnDestroy {
@ViewChild("loginApproval", { read: ViewContainerRef, static: true }) @ViewChild("loginApproval", { read: ViewContainerRef, static: true })
loginApprovalModalRef: ViewContainerRef; loginApprovalModalRef: ViewContainerRef;
showHeader$ = this.accountService.showHeader$;
loading = false; loading = false;
private lastActivity: Date = null; private lastActivity: Date = null;

View File

@@ -1,122 +0,0 @@
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";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@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
type="button"
buttonType="secondary"
(click)="closeModal()"
>
Close
</button>
</div>
`,
})
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.setModalMode(false);
this.session.notifyConfirmNewCredential(false);
// little bit hacky:
this.session.confirmChosenCipher(null);
}
}

View File

@@ -345,6 +345,7 @@ const safeProviders: SafeProvider[] = [
ConfigService, ConfigService,
Fido2AuthenticatorServiceAbstraction, Fido2AuthenticatorServiceAbstraction,
AccountService, AccountService,
AuthService,
PlatformUtilsService, PlatformUtilsService,
], ],
}), }),

View File

@@ -0,0 +1,42 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { combineLatest, map, switchMap, distinctUntilChanged } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
/**
* Reactive route guard that redirects to the unlocked vault.
* Redirects to vault when unlocked in main window.
*/
export const reactiveUnlockVaultGuard: CanActivateFn = () => {
const router = inject(Router);
const authService = inject(AuthService);
const accountService = inject(AccountService);
const desktopSettingsService = inject(DesktopSettingsService);
return combineLatest([accountService.activeAccount$, desktopSettingsService.modalMode$]).pipe(
switchMap(([account, modalMode]) => {
if (!account) {
return [true];
}
// Monitor when the vault has been unlocked.
return authService.authStatusFor$(account.id).pipe(
distinctUntilChanged(),
map((authStatus) => {
// If vault is unlocked and we're not in modal mode, redirect to vault
if (authStatus === AuthenticationStatus.Unlocked && !modalMode?.isModalModeActive) {
return router.createUrlTree(["/vault"]);
}
// Otherwise keep user on the lock screen
return true;
}),
);
}),
);
};

View File

@@ -0,0 +1,66 @@
<div class="tw-flex tw-flex-col tw-h-full tw-bg-background-alt">
<bit-section
disableMargin
class="tw-sticky tw-top-0 tw-z-10 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
>
<bit-section-header class="tw-app-region-drag tw-bg-background">
<div class="tw-flex tw-items-center">
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">
{{ "savePasskeyQuestion" | i18n }}
</h2>
</div>
<button
type="button"
bitIconButton="bwi-close"
slot="end"
class="tw-app-region-no-drag tw-mb-4 tw-mr-2"
(click)="closeModal()"
[label]="'close' | i18n"
>
{{ "close" | i18n }}
</button>
</bit-section-header>
</bit-section>
<bit-section class="tw-bg-background-alt tw-p-4 tw-flex tw-flex-col">
<div *ngIf="(ciphers$ | async)?.length === 0; else hasCiphers">
<div class="tw-flex tw-items-center tw-flex-col tw-p-12 tw-gap-4">
<bit-icon [icon]="Icons.NoResults" class="tw-text-main"></bit-icon>
<div class="tw-flex tw-flex-col tw-gap-2">
{{ "noMatchingLoginsForSite" | i18n }}
</div>
<button bitButton type="button" buttonType="primary" (click)="confirmPasskey()">
{{ "savePasskeyNewLogin" | i18n }}
</button>
</div>
</div>
<ng-template #hasCiphers>
<bit-item *ngFor="let c of ciphers$ | async" class="">
<button type="button" bit-item-content (click)="addCredentialToCipher(c)">
<app-vault-icon [cipher]="c" slot="start"></app-vault-icon>
<button bitLink [title]="c.name" type="button">
{{ c.name }}
</button>
<span slot="secondary">{{ c.subTitle }}</span>
<span bitBadge slot="end">{{ "save" | i18n }}</span>
</button>
</bit-item>
<bit-item class="">
<button
bitLink
linkType="primary"
type="button"
bit-item-content
(click)="confirmPasskey()"
>
<a bitLink linkType="primary" class="tw-font-medium tw-text-base">
{{ "saveNewPasskey" | i18n }}
</a>
</button>
</bit-item>
</ng-template>
</bit-section>
</div>

View File

@@ -0,0 +1,238 @@
import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service";
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../services/desktop-fido2-user-interface.service";
import { Fido2CreateComponent } from "./fido2-create.component";
describe("Fido2CreateComponent", () => {
let component: Fido2CreateComponent;
let mockDesktopSettingsService: MockProxy<DesktopSettingsService>;
let mockFido2UserInterfaceService: MockProxy<DesktopFido2UserInterfaceService>;
let mockAccountService: MockProxy<AccountService>;
let mockCipherService: MockProxy<CipherService>;
let mockDesktopAutofillService: MockProxy<DesktopAutofillService>;
let mockDialogService: MockProxy<DialogService>;
let mockDomainSettingsService: MockProxy<DomainSettingsService>;
let mockLogService: MockProxy<LogService>;
let mockPasswordRepromptService: MockProxy<PasswordRepromptService>;
let mockRouter: MockProxy<Router>;
let mockSession: MockProxy<DesktopFido2UserInterfaceSession>;
let mockI18nService: MockProxy<I18nService>;
const activeAccountSubject = new BehaviorSubject<Account | null>({
id: "test-user-id" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
});
beforeEach(async () => {
mockDesktopSettingsService = mock<DesktopSettingsService>();
mockFido2UserInterfaceService = mock<DesktopFido2UserInterfaceService>();
mockAccountService = mock<AccountService>();
mockCipherService = mock<CipherService>();
mockDesktopAutofillService = mock<DesktopAutofillService>();
mockDialogService = mock<DialogService>();
mockDomainSettingsService = mock<DomainSettingsService>();
mockLogService = mock<LogService>();
mockPasswordRepromptService = mock<PasswordRepromptService>();
mockRouter = mock<Router>();
mockSession = mock<DesktopFido2UserInterfaceSession>();
mockI18nService = mock<I18nService>();
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession);
mockAccountService.activeAccount$ = activeAccountSubject;
await TestBed.configureTestingModule({
providers: [
Fido2CreateComponent,
{ provide: DesktopSettingsService, useValue: mockDesktopSettingsService },
{ provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: CipherService, useValue: mockCipherService },
{ provide: DesktopAutofillService, useValue: mockDesktopAutofillService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: DomainSettingsService, useValue: mockDomainSettingsService },
{ provide: LogService, useValue: mockLogService },
{ provide: PasswordRepromptService, useValue: mockPasswordRepromptService },
{ provide: Router, useValue: mockRouter },
{ provide: I18nService, useValue: mockI18nService },
],
}).compileComponents();
component = TestBed.inject(Fido2CreateComponent);
});
afterEach(() => {
jest.restoreAllMocks();
});
function createMockCiphers(): CipherView[] {
const cipher1 = new CipherView();
cipher1.id = "cipher-1";
cipher1.name = "Test Cipher 1";
cipher1.type = CipherType.Login;
cipher1.login = {
username: "test1@example.com",
uris: [{ uri: "https://example.com", match: null }],
matchesUri: jest.fn().mockReturnValue(true),
get hasFido2Credentials() {
return false;
},
} as any;
cipher1.reprompt = CipherRepromptType.None;
cipher1.deletedDate = null;
return [cipher1];
}
describe("ngOnInit", () => {
beforeEach(() => {
mockSession.getRpId.mockResolvedValue("example.com");
Object.defineProperty(mockDesktopAutofillService, "lastRegistrationRequest", {
get: jest.fn().mockReturnValue({
userHandle: new Uint8Array([1, 2, 3]),
}),
configurable: true,
});
mockDomainSettingsService.getUrlEquivalentDomains.mockReturnValue(of(new Set<string>()));
});
it("should initialize session and set show header to false", async () => {
const mockCiphers = createMockCiphers();
mockCipherService.getAllDecrypted.mockResolvedValue(mockCiphers);
await component.ngOnInit();
expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled();
expect(component.session).toBe(mockSession);
});
it("should show error dialog when no active session found", async () => {
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(null);
mockDialogService.openSimpleDialog.mockResolvedValue(false);
await component.ngOnInit();
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "unableToSavePasskey" },
content: { key: "closeThisBitwardenWindow" },
type: "danger",
acceptButtonText: { key: "closeThisWindow" },
acceptAction: expect.any(Function),
cancelButtonText: null,
});
});
});
describe("addCredentialToCipher", () => {
beforeEach(() => {
component.session = mockSession;
});
it("should add passkey to cipher", async () => {
const cipher = createMockCiphers()[0];
await component.addCredentialToCipher(cipher);
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher);
});
it("should not add passkey when password reprompt is cancelled", async () => {
const cipher = createMockCiphers()[0];
cipher.reprompt = CipherRepromptType.Password;
mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false);
await component.addCredentialToCipher(cipher);
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher);
});
it("should call openSimpleDialog when cipher already has a fido2 credential", async () => {
const cipher = createMockCiphers()[0];
Object.defineProperty(cipher.login, "hasFido2Credentials", {
get: jest.fn().mockReturnValue(true),
});
mockDialogService.openSimpleDialog.mockResolvedValue(true);
await component.addCredentialToCipher(cipher);
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "overwritePasskey" },
content: { key: "alreadyContainsPasskey" },
type: "warning",
});
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher);
});
it("should not add passkey when user cancels overwrite dialog", async () => {
const cipher = createMockCiphers()[0];
Object.defineProperty(cipher.login, "hasFido2Credentials", {
get: jest.fn().mockReturnValue(true),
});
mockDialogService.openSimpleDialog.mockResolvedValue(false);
await component.addCredentialToCipher(cipher);
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher);
});
});
describe("confirmPasskey", () => {
beforeEach(() => {
component.session = mockSession;
});
it("should confirm passkey creation successfully", async () => {
await component.confirmPasskey();
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true);
});
it("should call openSimpleDialog when session is null", async () => {
component.session = null;
mockDialogService.openSimpleDialog.mockResolvedValue(false);
await component.confirmPasskey();
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "unableToSavePasskey" },
content: { key: "closeThisBitwardenWindow" },
type: "danger",
acceptButtonText: { key: "closeThisWindow" },
acceptAction: expect.any(Function),
cancelButtonText: null,
});
});
});
describe("closeModal", () => {
it("should close modal and notify session", async () => {
component.session = mockSession;
await component.closeModal();
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false);
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(null);
});
});
});

View File

@@ -0,0 +1,219 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core";
import { RouterModule, Router } from "@angular/router";
import { combineLatest, map, Observable, Subject, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BitwardenShield, NoResults } from "@bitwarden/assets/svg";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
DialogService,
BadgeModule,
ButtonModule,
DialogModule,
IconModule,
ItemModule,
SectionComponent,
TableModule,
SectionHeaderComponent,
BitIconButtonComponent,
SimpleDialogOptions,
} from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service";
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../services/desktop-fido2-user-interface.service";
@Component({
standalone: true,
imports: [
CommonModule,
RouterModule,
SectionHeaderComponent,
BitIconButtonComponent,
TableModule,
JslibModule,
IconModule,
ButtonModule,
DialogModule,
SectionComponent,
ItemModule,
BadgeModule,
],
templateUrl: "fido2-create.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Fido2CreateComponent implements OnInit, OnDestroy {
session?: DesktopFido2UserInterfaceSession = null;
ciphers$: Observable<CipherView[]>;
private destroy$ = new Subject<void>();
readonly Icons = { BitwardenShield, NoResults };
private get DIALOG_MESSAGES() {
return {
unexpectedErrorShort: {
title: { key: "unexpectedErrorShort" },
content: { key: "closeThisBitwardenWindow" },
type: "danger",
acceptButtonText: { key: "closeThisWindow" },
cancelButtonText: null as null,
acceptAction: async () => this.dialogService.closeAll(),
},
unableToSavePasskey: {
title: { key: "unableToSavePasskey" },
content: { key: "closeThisBitwardenWindow" },
type: "danger",
acceptButtonText: { key: "closeThisWindow" },
cancelButtonText: null as null,
acceptAction: async () => this.dialogService.closeAll(),
},
overwritePasskey: {
title: { key: "overwritePasskey" },
content: { key: "alreadyContainsPasskey" },
type: "warning",
},
} as const satisfies Record<string, SimpleDialogOptions>;
}
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly accountService: AccountService,
private readonly cipherService: CipherService,
private readonly desktopAutofillService: DesktopAutofillService,
private readonly dialogService: DialogService,
private readonly domainSettingsService: DomainSettingsService,
private readonly passwordRepromptService: PasswordRepromptService,
private readonly router: Router,
) {}
async ngOnInit(): Promise<void> {
this.session = this.fido2UserInterfaceService.getCurrentSession();
if (this.session) {
const rpid = await this.session.getRpId();
this.initializeCiphersObservable(rpid);
} else {
await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
}
}
async ngOnDestroy(): Promise<void> {
this.destroy$.next();
this.destroy$.complete();
await this.closeModal();
}
async addCredentialToCipher(cipher: CipherView): Promise<void> {
const isConfirmed = await this.validateCipherAccess(cipher);
try {
if (!this.session) {
throw new Error("Missing session");
}
this.session.notifyConfirmCreateCredential(isConfirmed, cipher);
} catch {
await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
return;
}
await this.closeModal();
}
async confirmPasskey(): Promise<void> {
try {
if (!this.session) {
throw new Error("Missing session");
}
this.session.notifyConfirmCreateCredential(true);
} catch {
await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
}
await this.closeModal();
}
async closeModal(): Promise<void> {
await this.desktopSettingsService.setModalMode(false);
await this.accountService.setShowHeader(true);
if (this.session) {
this.session.notifyConfirmCreateCredential(false);
this.session.confirmChosenCipher(null);
}
await this.router.navigate(["/"]);
}
private initializeCiphersObservable(rpid: string): void {
const lastRegistrationRequest = this.desktopAutofillService.lastRegistrationRequest;
if (!lastRegistrationRequest || !rpid) {
return;
}
const userHandle = Fido2Utils.bufferToString(
new Uint8Array(lastRegistrationRequest.userHandle),
);
this.ciphers$ = combineLatest([
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
this.domainSettingsService.getUrlEquivalentDomains(rpid),
]).pipe(
switchMap(async ([activeUserId, equivalentDomains]) => {
if (!activeUserId) {
return [];
}
try {
const allCiphers = await this.cipherService.getAllDecrypted(activeUserId);
return allCiphers.filter(
(cipher) =>
cipher != null &&
cipher.type == CipherType.Login &&
cipher.login?.matchesUri(rpid, equivalentDomains) &&
Fido2Utils.cipherHasNoOtherPasskeys(cipher, userHandle) &&
!cipher.deletedDate,
);
} catch {
await this.showErrorDialog(this.DIALOG_MESSAGES.unexpectedErrorShort);
return [];
}
}),
);
}
private async validateCipherAccess(cipher: CipherView): Promise<boolean> {
if (cipher.login.hasFido2Credentials) {
const overwriteConfirmed = await this.dialogService.openSimpleDialog(
this.DIALOG_MESSAGES.overwritePasskey,
);
if (!overwriteConfirmed) {
return false;
}
}
if (cipher.reprompt) {
return this.passwordRepromptService.showPasswordPrompt();
}
return true;
}
private async showErrorDialog(config: SimpleDialogOptions): Promise<void> {
await this.dialogService.openSimpleDialog(config);
await this.closeModal();
}
}

View File

@@ -0,0 +1,44 @@
<div class="tw-flex tw-flex-col tw-h-full">
<bit-section
disableMargin
class="tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
>
<bit-section-header class="tw-app-region-drag tw-bg-background">
<div class="tw-flex tw-items-center">
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">
{{ "savePasskeyQuestion" | i18n }}
</h2>
</div>
<button
type="button"
bitIconButton="bwi-close"
slot="end"
class="tw-app-region-no-drag tw-mb-4 tw-mr-2"
(click)="closeModal()"
[label]="'close' | i18n"
>
{{ "close" | i18n }}
</button>
</bit-section-header>
</bit-section>
<div class="tw-h-full tw-items-start">
<bit-section
class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-start tw-items-center tw-gap-2 tw-h-full tw-px-5"
>
<div class="tw-flex tw-items-center tw-flex-col tw-p-12 tw-gap-4">
<bit-icon [icon]="Icons.NoResults" class="tw-text-main"></bit-icon>
<div class="tw-flex tw-flex-col tw-gap-2">
<b>{{ "passkeyAlreadyExists" | i18n }}</b>
{{ "applicationDoesNotSupportDuplicates" | i18n }}
</div>
<button bitButton type="button" buttonType="primary" (click)="closeModal()">
{{ "close" | i18n }}
</button>
</div>
</bit-section>
</div>
</div>

View File

@@ -0,0 +1,78 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../services/desktop-fido2-user-interface.service";
import { Fido2ExcludedCiphersComponent } from "./fido2-excluded-ciphers.component";
describe("Fido2ExcludedCiphersComponent", () => {
let component: Fido2ExcludedCiphersComponent;
let fixture: ComponentFixture<Fido2ExcludedCiphersComponent>;
let mockDesktopSettingsService: MockProxy<DesktopSettingsService>;
let mockFido2UserInterfaceService: MockProxy<DesktopFido2UserInterfaceService>;
let mockAccountService: MockProxy<AccountService>;
let mockRouter: MockProxy<Router>;
let mockSession: MockProxy<DesktopFido2UserInterfaceSession>;
let mockI18nService: MockProxy<I18nService>;
beforeEach(async () => {
mockDesktopSettingsService = mock<DesktopSettingsService>();
mockFido2UserInterfaceService = mock<DesktopFido2UserInterfaceService>();
mockAccountService = mock<AccountService>();
mockRouter = mock<Router>();
mockSession = mock<DesktopFido2UserInterfaceSession>();
mockI18nService = mock<I18nService>();
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession);
await TestBed.configureTestingModule({
imports: [Fido2ExcludedCiphersComponent],
providers: [
{ provide: DesktopSettingsService, useValue: mockDesktopSettingsService },
{ provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: Router, useValue: mockRouter },
{ provide: I18nService, useValue: mockI18nService },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(Fido2ExcludedCiphersComponent);
component = fixture.componentInstance;
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("ngOnInit", () => {
it("should initialize session", async () => {
await component.ngOnInit();
expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled();
expect(component.session).toBe(mockSession);
});
});
describe("closeModal", () => {
it("should close modal and notify session when session exists", async () => {
component.session = mockSession;
await component.closeModal();
expect(mockDesktopSettingsService.setModalMode).toHaveBeenCalledWith(false);
expect(mockAccountService.setShowHeader).toHaveBeenCalledWith(true);
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false);
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
});
});
});

View File

@@ -0,0 +1,78 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core";
import { RouterModule, Router } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BitwardenShield, NoResults } from "@bitwarden/assets/svg";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
BadgeModule,
ButtonModule,
DialogModule,
IconModule,
ItemModule,
SectionComponent,
TableModule,
SectionHeaderComponent,
BitIconButtonComponent,
} from "@bitwarden/components";
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../services/desktop-fido2-user-interface.service";
@Component({
standalone: true,
imports: [
CommonModule,
RouterModule,
SectionHeaderComponent,
BitIconButtonComponent,
TableModule,
JslibModule,
IconModule,
ButtonModule,
DialogModule,
SectionComponent,
ItemModule,
BadgeModule,
],
templateUrl: "fido2-excluded-ciphers.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Fido2ExcludedCiphersComponent implements OnInit, OnDestroy {
session?: DesktopFido2UserInterfaceSession = null;
readonly Icons = { BitwardenShield, NoResults };
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly accountService: AccountService,
private readonly router: Router,
) {}
async ngOnInit(): Promise<void> {
this.session = this.fido2UserInterfaceService.getCurrentSession();
}
async ngOnDestroy(): Promise<void> {
await this.closeModal();
}
async closeModal(): Promise<void> {
// Clean up modal state
await this.desktopSettingsService.setModalMode(false);
await this.accountService.setShowHeader(true);
// Clean up session state
if (this.session) {
this.session.notifyConfirmCreateCredential(false);
this.session.confirmChosenCipher(null);
}
// Navigate away
await this.router.navigate(["/"]);
}
}

View File

@@ -0,0 +1,37 @@
<div class="tw-flex tw-flex-col tw-h-full">
<bit-section
disableMargin
class="tw-sticky tw-top-0 tw-z-10 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
>
<bit-section-header class="tw-app-region-drag tw-bg-background">
<div class="tw-flex tw-items-center">
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">{{ "passkeyLogin" | i18n }}</h2>
</div>
<button
type="button"
bitIconButton="bwi-close"
slot="end"
class="tw-app-region-no-drag tw-mb-4 tw-mr-2"
(click)="closeModal()"
[label]="'close' | i18n"
>
{{ "close" | i18n }}
</button>
</bit-section-header>
</bit-section>
<bit-section class="tw-bg-background-alt tw-p-4 tw-flex tw-flex-col tw-grow">
<bit-item *ngFor="let c of ciphers$ | async" class="">
<button type="button" bit-item-content (click)="chooseCipher(c)">
<app-vault-icon [cipher]="c" slot="start"></app-vault-icon>
<button bitLink [title]="c.name" type="button">
{{ c.name }}
</button>
<span slot="secondary">{{ c.subTitle }}</span>
<span bitBadge slot="end">{{ "select" | i18n }}</span>
</button>
</bit-item>
</bit-section>
</div>

View File

@@ -0,0 +1,196 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { PasswordRepromptService } from "@bitwarden/vault";
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../services/desktop-fido2-user-interface.service";
import { Fido2VaultComponent } from "./fido2-vault.component";
describe("Fido2VaultComponent", () => {
let component: Fido2VaultComponent;
let fixture: ComponentFixture<Fido2VaultComponent>;
let mockDesktopSettingsService: MockProxy<DesktopSettingsService>;
let mockFido2UserInterfaceService: MockProxy<DesktopFido2UserInterfaceService>;
let mockCipherService: MockProxy<CipherService>;
let mockAccountService: MockProxy<AccountService>;
let mockLogService: MockProxy<LogService>;
let mockPasswordRepromptService: MockProxy<PasswordRepromptService>;
let mockRouter: MockProxy<Router>;
let mockSession: MockProxy<DesktopFido2UserInterfaceSession>;
let mockI18nService: MockProxy<I18nService>;
const mockActiveAccount = { id: "test-user-id", email: "test@example.com" };
const mockCipherIds = ["cipher-1", "cipher-2", "cipher-3"];
beforeEach(async () => {
mockDesktopSettingsService = mock<DesktopSettingsService>();
mockFido2UserInterfaceService = mock<DesktopFido2UserInterfaceService>();
mockCipherService = mock<CipherService>();
mockAccountService = mock<AccountService>();
mockLogService = mock<LogService>();
mockPasswordRepromptService = mock<PasswordRepromptService>();
mockRouter = mock<Router>();
mockSession = mock<DesktopFido2UserInterfaceSession>();
mockI18nService = mock<I18nService>();
mockAccountService.activeAccount$ = of(mockActiveAccount as Account);
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession);
mockSession.availableCipherIds$ = of(mockCipherIds);
mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of([]));
await TestBed.configureTestingModule({
imports: [Fido2VaultComponent],
providers: [
{ provide: DesktopSettingsService, useValue: mockDesktopSettingsService },
{ provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService },
{ provide: CipherService, useValue: mockCipherService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: LogService, useValue: mockLogService },
{ provide: PasswordRepromptService, useValue: mockPasswordRepromptService },
{ provide: Router, useValue: mockRouter },
{ provide: I18nService, useValue: mockI18nService },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(Fido2VaultComponent);
component = fixture.componentInstance;
});
const mockCiphers: any[] = [
{
id: "cipher-1",
name: "Test Cipher 1",
type: CipherType.Login,
login: {
username: "test1@example.com",
},
reprompt: CipherRepromptType.None,
deletedDate: null,
},
{
id: "cipher-2",
name: "Test Cipher 2",
type: CipherType.Login,
login: {
username: "test2@example.com",
},
reprompt: CipherRepromptType.None,
deletedDate: null,
},
{
id: "cipher-3",
name: "Test Cipher 3",
type: CipherType.Login,
login: {
username: "test3@example.com",
},
reprompt: CipherRepromptType.Password,
deletedDate: null,
},
];
describe("ngOnInit", () => {
it("should initialize session and load ciphers successfully", async () => {
mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of(mockCiphers));
await component.ngOnInit();
expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled();
expect(component.session).toBe(mockSession);
expect(component.cipherIds$).toBe(mockSession.availableCipherIds$);
expect(mockCipherService.cipherListViews$).toHaveBeenCalledWith(mockActiveAccount.id);
});
it("should handle when no active session found", async () => {
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(null);
await component.ngOnInit();
expect(component.session).toBeNull();
});
it("should filter out deleted ciphers", async () => {
const ciphersWithDeleted = [
...mockCiphers.slice(0, 1),
{ ...mockCiphers[1], deletedDate: new Date() },
...mockCiphers.slice(2),
];
mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of(ciphersWithDeleted));
await component.ngOnInit();
await new Promise((resolve) => setTimeout(resolve, 0));
let ciphersResult: CipherView[] = [];
component.ciphers$.subscribe((ciphers) => {
ciphersResult = ciphers;
});
expect(ciphersResult).toHaveLength(2);
expect(ciphersResult.every((cipher) => !cipher.deletedDate)).toBe(true);
});
});
describe("chooseCipher", () => {
const cipher = mockCiphers[0];
beforeEach(() => {
component.session = mockSession;
});
it("should choose cipher when access is validated", async () => {
cipher.reprompt = CipherRepromptType.None;
await component.chooseCipher(cipher);
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, true);
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
});
it("should prompt for password when cipher requires reprompt", async () => {
cipher.reprompt = CipherRepromptType.Password;
mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(true);
await component.chooseCipher(cipher);
expect(mockPasswordRepromptService.showPasswordPrompt).toHaveBeenCalled();
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, true);
});
it("should not choose cipher when password reprompt is cancelled", async () => {
cipher.reprompt = CipherRepromptType.Password;
mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false);
await component.chooseCipher(cipher);
expect(mockPasswordRepromptService.showPasswordPrompt).toHaveBeenCalled();
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, false);
});
});
describe("closeModal", () => {
it("should close modal and notify session", async () => {
component.session = mockSession;
await component.closeModal();
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false);
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(null);
});
});
});

View File

@@ -0,0 +1,161 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core";
import { RouterModule, Router } from "@angular/router";
import {
firstValueFrom,
map,
combineLatest,
of,
BehaviorSubject,
Observable,
Subject,
takeUntil,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BitwardenShield } from "@bitwarden/assets/svg";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
BadgeModule,
ButtonModule,
DialogModule,
DialogService,
IconModule,
ItemModule,
SectionComponent,
TableModule,
BitIconButtonComponent,
SectionHeaderComponent,
} from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../services/desktop-fido2-user-interface.service";
@Component({
standalone: true,
imports: [
CommonModule,
RouterModule,
SectionHeaderComponent,
BitIconButtonComponent,
TableModule,
JslibModule,
IconModule,
ButtonModule,
DialogModule,
SectionComponent,
ItemModule,
BadgeModule,
],
templateUrl: "fido2-vault.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Fido2VaultComponent implements OnInit, OnDestroy {
session?: DesktopFido2UserInterfaceSession = null;
private destroy$ = new Subject<void>();
private ciphersSubject = new BehaviorSubject<CipherView[]>([]);
ciphers$: Observable<CipherView[]> = this.ciphersSubject.asObservable();
cipherIds$: Observable<string[]> | undefined;
readonly Icons = { BitwardenShield };
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly cipherService: CipherService,
private readonly accountService: AccountService,
private readonly dialogService: DialogService,
private readonly logService: LogService,
private readonly passwordRepromptService: PasswordRepromptService,
private readonly router: Router,
) {}
async ngOnInit(): Promise<void> {
this.session = this.fido2UserInterfaceService.getCurrentSession();
this.cipherIds$ = this.session?.availableCipherIds$;
await this.loadCiphers();
}
async ngOnDestroy(): Promise<void> {
this.destroy$.next();
this.destroy$.complete();
}
async chooseCipher(cipher: CipherView): Promise<void> {
if (!this.session) {
await this.dialogService.openSimpleDialog({
title: { key: "unexpectedErrorShort" },
content: { key: "closeThisBitwardenWindow" },
type: "danger",
acceptButtonText: { key: "closeThisWindow" },
cancelButtonText: null,
});
await this.closeModal();
return;
}
const isConfirmed = await this.validateCipherAccess(cipher);
this.session.confirmChosenCipher(cipher.id, isConfirmed);
await this.closeModal();
}
async closeModal(): Promise<void> {
await this.desktopSettingsService.setModalMode(false);
await this.accountService.setShowHeader(true);
if (this.session) {
this.session.notifyConfirmCreateCredential(false);
this.session.confirmChosenCipher(null);
}
await this.router.navigate(["/"]);
}
private async loadCiphers(): Promise<void> {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
if (!activeUserId) {
return;
}
// Combine cipher list with optional cipher IDs filter
combineLatest([this.cipherService.cipherListViews$(activeUserId), this.cipherIds$ || of(null)])
.pipe(
map(([ciphers, cipherIds]) => {
// Filter out deleted ciphers
const activeCiphers = ciphers.filter((cipher) => !cipher.deletedDate);
// If specific IDs provided, filter by them
if (cipherIds?.length > 0) {
return activeCiphers.filter((cipher) => cipherIds.includes(cipher.id as string));
}
return activeCiphers;
}),
takeUntil(this.destroy$),
)
.subscribe({
next: (ciphers) => this.ciphersSubject.next(ciphers as CipherView[]),
error: (error: unknown) => this.logService.error("Failed to load ciphers", error),
});
}
private async validateCipherAccess(cipher: CipherView): Promise<boolean> {
if (cipher.reprompt !== CipherRepromptType.None) {
return this.passwordRepromptService.showPasswordPrompt();
}
return true;
}
}

View File

@@ -12,6 +12,8 @@ export default {
runCommand: <C extends Command>(params: RunCommandParams<C>): Promise<RunCommandResult<C>> => runCommand: <C extends Command>(params: RunCommandParams<C>): Promise<RunCommandResult<C>> =>
ipcRenderer.invoke("autofill.runCommand", params), ipcRenderer.invoke("autofill.runCommand", params),
listenerReady: () => ipcRenderer.send("autofill.listenerReady"),
listenPasskeyRegistration: ( listenPasskeyRegistration: (
fn: ( fn: (
clientId: number, clientId: number,
@@ -130,6 +132,25 @@ export default {
}, },
); );
}, },
listenNativeStatus: (
fn: (clientId: number, sequenceNumber: number, status: { key: string; value: string }) => void,
) => {
ipcRenderer.on(
"autofill.nativeStatus",
(
event,
data: {
clientId: number;
sequenceNumber: number;
status: { key: string; value: string };
},
) => {
const { clientId, sequenceNumber, status } = data;
fn(clientId, sequenceNumber, status);
},
);
},
configureAutotype: (enabled: boolean, keyboardShortcut: string[]) => { configureAutotype: (enabled: boolean, keyboardShortcut: string[]) => {
ipcRenderer.send("autofill.configureAutotype", { enabled, keyboardShortcut }); ipcRenderer.send("autofill.configureAutotype", { enabled, keyboardShortcut });
}, },

View File

@@ -1,6 +1,8 @@
import { Injectable, OnDestroy } from "@angular/core"; import { Injectable, OnDestroy } from "@angular/core";
import { import {
Subject, Subject,
combineLatest,
debounceTime,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
firstValueFrom, firstValueFrom,
@@ -8,10 +10,11 @@ import {
mergeMap, mergeMap,
switchMap, switchMap,
takeUntil, takeUntil,
EMPTY,
} from "rxjs"; } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service"; import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service";
import { DeviceType } from "@bitwarden/common/enums"; import { DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -48,6 +51,7 @@ import type { NativeWindowObject } from "./desktop-fido2-user-interface.service"
@Injectable() @Injectable()
export class DesktopAutofillService implements OnDestroy { export class DesktopAutofillService implements OnDestroy {
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
private registrationRequest: autofill.PasskeyRegistrationRequest;
constructor( constructor(
private logService: LogService, private logService: LogService,
@@ -55,6 +59,7 @@ export class DesktopAutofillService implements OnDestroy {
private configService: ConfigService, private configService: ConfigService,
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>, private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>,
private accountService: AccountService, private accountService: AccountService,
private authService: AuthService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
) {} ) {}
@@ -68,28 +73,56 @@ export class DesktopAutofillService implements OnDestroy {
.getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync) .getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync)
.pipe( .pipe(
distinctUntilChanged(), distinctUntilChanged(),
switchMap((enabled) => { filter((enabled) => enabled === true), // Only proceed if feature is enabled
if (!enabled) { switchMap(() => {
return EMPTY; return combineLatest([
} this.accountService.activeAccount$.pipe(
map((account) => account?.id),
return this.accountService.activeAccount$.pipe( filter((userId): userId is UserId => userId != null),
map((account) => account?.id), ),
filter((userId): userId is UserId => userId != null), this.authService.activeAccountStatus$,
switchMap((userId) => this.cipherService.cipherViews$(userId)), ]).pipe(
// Only proceed when the vault is unlocked
filter(([, status]) => status === AuthenticationStatus.Unlocked),
// Then get cipher views
switchMap(([userId]) => this.cipherService.cipherViews$(userId)),
); );
}), }),
// TODO: This will unset all the autofill credentials on the OS debounceTime(100), // just a precaution to not spam the sync if there are multiple changes (we typically observe a null change)
// when the account locks. We should instead explicilty clear the credentials // No filter for empty arrays here - we want to sync even if there are 0 items
// when the user logs out. Maybe by subscribing to the encrypted ciphers observable instead. filter((cipherViewMap) => cipherViewMap !== null),
mergeMap((cipherViewMap) => this.sync(Object.values(cipherViewMap ?? []))), mergeMap((cipherViewMap) => this.sync(Object.values(cipherViewMap ?? []))),
takeUntil(this.destroy$), takeUntil(this.destroy$),
) )
.subscribe(); .subscribe();
// Listen for sign out to clear credentials
this.authService.activeAccountStatus$
.pipe(
filter((status) => status === AuthenticationStatus.LoggedOut),
mergeMap(() => this.sync([])), // sync an empty array
takeUntil(this.destroy$),
)
.subscribe();
this.listenIpc(); this.listenIpc();
} }
async adHocSync(): Promise<any> {
this.logService.debug("Performing AdHoc sync");
const account = await firstValueFrom(this.accountService.activeAccount$);
const userId = account?.id;
if (!userId) {
throw new Error("No active user found");
}
const cipherViewMap = await firstValueFrom(this.cipherService.cipherViews$(userId));
this.logService.info("Performing AdHoc sync", Object.values(cipherViewMap ?? []));
await this.sync(Object.values(cipherViewMap ?? []));
}
/** Give metadata about all available credentials in the users vault */ /** Give metadata about all available credentials in the users vault */
async sync(cipherViews: CipherView[]) { async sync(cipherViews: CipherView[]) {
const status = await this.status(); const status = await this.status();
@@ -130,6 +163,11 @@ export class DesktopAutofillService implements OnDestroy {
})); }));
} }
this.logService.info("Syncing autofill credentials", {
fido2Credentials,
passwordCredentials,
});
const syncResult = await ipc.autofill.runCommand<NativeAutofillSyncCommand>({ const syncResult = await ipc.autofill.runCommand<NativeAutofillSyncCommand>({
namespace: "autofill", namespace: "autofill",
command: "sync", command: "sync",
@@ -155,107 +193,152 @@ export class DesktopAutofillService implements OnDestroy {
}); });
} }
get lastRegistrationRequest() {
return this.registrationRequest;
}
listenIpc() { listenIpc() {
ipc.autofill.listenPasskeyRegistration((clientId, sequenceNumber, request, callback) => { ipc.autofill.listenPasskeyRegistration(async (clientId, sequenceNumber, request, callback) => {
this.logService.warning("listenPasskeyRegistration", clientId, sequenceNumber, request); if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
this.logService.warning( this.logService.debug(
"listenPasskeyRegistration2", "listenPasskeyRegistration: MacOsNativeCredentialSync feature flag is disabled",
this.convertRegistrationRequest(request), );
); callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
return;
}
this.registrationRequest = request;
this.logService.debug("listenPasskeyRegistration", clientId, sequenceNumber, request);
this.logService.debug("listenPasskeyRegistration2", this.convertRegistrationRequest(request));
const controller = new AbortController(); const controller = new AbortController();
void this.fido2AuthenticatorService
.makeCredential( try {
const response = await this.fido2AuthenticatorService.makeCredential(
this.convertRegistrationRequest(request), this.convertRegistrationRequest(request),
{ windowXy: request.windowXy }, { windowXy: normalizePosition(request.windowXy) },
controller, controller,
) );
.then((response) => {
callback(null, this.convertRegistrationResponse(request, response)); callback(null, this.convertRegistrationResponse(request, response));
}) } catch (error) {
.catch((error) => { this.logService.error("listenPasskeyRegistration error", error);
this.logService.error("listenPasskeyRegistration error", error); callback(error, null);
callback(error, null); }
});
}); });
ipc.autofill.listenPasskeyAssertionWithoutUserInterface( ipc.autofill.listenPasskeyAssertionWithoutUserInterface(
async (clientId, sequenceNumber, request, callback) => { async (clientId, sequenceNumber, request, callback) => {
this.logService.warning( if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
this.logService.debug(
"listenPasskeyAssertionWithoutUserInterface: MacOsNativeCredentialSync feature flag is disabled",
);
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
return;
}
this.logService.debug(
"listenPasskeyAssertion without user interface", "listenPasskeyAssertion without user interface",
clientId, clientId,
sequenceNumber, sequenceNumber,
request, 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 this.cipherService.decrypt(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(
new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)),
);
}
const controller = new AbortController(); const controller = new AbortController();
void this.fido2AuthenticatorService
.getAssertion( try {
this.convertAssertionRequest(request), // For some reason the credentialId is passed as an empty array in the request, so we need to
{ windowXy: request.windowXy }, // 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 this.cipherService.decrypt(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(
new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)),
);
}
const response = await this.fido2AuthenticatorService.getAssertion(
this.convertAssertionRequest(request, true),
{ windowXy: normalizePosition(request.windowXy) },
controller, controller,
) );
.then((response) => {
callback(null, this.convertAssertionResponse(request, response)); callback(null, this.convertAssertionResponse(request, response));
}) } catch (error) {
.catch((error) => { this.logService.error("listenPasskeyAssertion error", error);
this.logService.error("listenPasskeyAssertion error", error); callback(error, null);
callback(error, null); return;
}); }
}, },
); );
ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => { ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => {
this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request); if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
this.logService.debug(
"listenPasskeyAssertion: MacOsNativeCredentialSync feature flag is disabled",
);
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
return;
}
this.logService.debug("listenPasskeyAssertion", clientId, sequenceNumber, request);
const controller = new AbortController(); const controller = new AbortController();
void this.fido2AuthenticatorService try {
.getAssertion( const response = await this.fido2AuthenticatorService.getAssertion(
this.convertAssertionRequest(request), this.convertAssertionRequest(request),
{ windowXy: request.windowXy }, { windowXy: normalizePosition(request.windowXy) },
controller, controller,
) );
.then((response) => {
callback(null, this.convertAssertionResponse(request, response)); callback(null, this.convertAssertionResponse(request, response));
}) } catch (error) {
.catch((error) => { this.logService.error("listenPasskeyAssertion error", error);
this.logService.error("listenPasskeyAssertion error", error); callback(error, null);
callback(error, null); }
});
}); });
// Listen for native status messages
ipc.autofill.listenNativeStatus(async (clientId, sequenceNumber, status) => {
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
this.logService.debug(
"listenNativeStatus: MacOsNativeCredentialSync feature flag is disabled",
);
return;
}
this.logService.info("Received native status", status.key, status.value);
if (status.key === "request-sync") {
// perform ad-hoc sync
await this.adHocSync();
}
});
ipc.autofill.listenerReady();
} }
private convertRegistrationRequest( private convertRegistrationRequest(
@@ -277,7 +360,10 @@ export class DesktopAutofillService implements OnDestroy {
alg, alg,
type: "public-key", type: "public-key",
})), })),
excludeCredentialDescriptorList: [], excludeCredentialDescriptorList: request.excludedCredentials.map((credentialId) => ({
id: new Uint8Array(credentialId),
type: "public-key" as const,
})),
requireResidentKey: true, requireResidentKey: true,
requireUserVerification: requireUserVerification:
request.userVerification === "required" || request.userVerification === "preferred", request.userVerification === "required" || request.userVerification === "preferred",
@@ -309,18 +395,19 @@ export class DesktopAutofillService implements OnDestroy {
request: request:
| autofill.PasskeyAssertionRequest | autofill.PasskeyAssertionRequest
| autofill.PasskeyAssertionWithoutUserInterfaceRequest, | autofill.PasskeyAssertionWithoutUserInterfaceRequest,
assumeUserPresence: boolean = false,
): Fido2AuthenticatorGetAssertionParams { ): Fido2AuthenticatorGetAssertionParams {
let allowedCredentials; let allowedCredentials;
if ("credentialId" in request) { if ("credentialId" in request) {
allowedCredentials = [ allowedCredentials = [
{ {
id: new Uint8Array(request.credentialId), id: new Uint8Array(request.credentialId).buffer,
type: "public-key" as const, type: "public-key" as const,
}, },
]; ];
} else { } else {
allowedCredentials = request.allowedCredentials.map((credentialId) => ({ allowedCredentials = request.allowedCredentials.map((credentialId) => ({
id: new Uint8Array(credentialId), id: new Uint8Array(credentialId).buffer,
type: "public-key" as const, type: "public-key" as const,
})); }));
} }
@@ -333,7 +420,7 @@ export class DesktopAutofillService implements OnDestroy {
requireUserVerification: requireUserVerification:
request.userVerification === "required" || request.userVerification === "preferred", request.userVerification === "required" || request.userVerification === "preferred",
fallbackSupported: false, fallbackSupported: false,
assumeUserPresence: true, // For desktop assertions, it's safe to assume UP has been checked by OS dialogues assumeUserPresence,
}; };
} }
@@ -358,3 +445,13 @@ export class DesktopAutofillService implements OnDestroy {
this.destroy$.complete(); this.destroy$.complete();
} }
} }
function normalizePosition(position: { x: number; y: number }): { x: number; y: number } {
// Add 100 pixels to the x-coordinate to offset the native OS dialog positioning.
const xPostionOffset = 100;
return {
x: Math.round(position.x + xPostionOffset),
y: Math.round(position.y),
};
}

View File

@@ -66,7 +66,7 @@ export class DesktopFido2UserInterfaceService
nativeWindowObject: NativeWindowObject, nativeWindowObject: NativeWindowObject,
abortController?: AbortController, abortController?: AbortController,
): Promise<DesktopFido2UserInterfaceSession> { ): Promise<DesktopFido2UserInterfaceSession> {
this.logService.warning("newSession", fallbackSupported, abortController, nativeWindowObject); this.logService.debug("newSession", fallbackSupported, abortController, nativeWindowObject);
const session = new DesktopFido2UserInterfaceSession( const session = new DesktopFido2UserInterfaceSession(
this.authService, this.authService,
this.cipherService, this.cipherService,
@@ -94,9 +94,11 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
) {} ) {}
private confirmCredentialSubject = new Subject<boolean>(); private confirmCredentialSubject = new Subject<boolean>();
private createdCipher: Cipher;
private availableCipherIdsSubject = new BehaviorSubject<string[]>(null); private updatedCipher: CipherView;
private rpId = new BehaviorSubject<string>(null);
private availableCipherIdsSubject = new BehaviorSubject<string[]>([""]);
/** /**
* Observable that emits available cipher IDs once they're confirmed by the UI * Observable that emits available cipher IDs once they're confirmed by the UI
*/ */
@@ -114,7 +116,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
assumeUserPresence, assumeUserPresence,
masterPasswordRepromptRequired, masterPasswordRepromptRequired,
}: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> { }: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
this.logService.warning("pickCredential desktop function", { this.logService.debug("pickCredential desktop function", {
cipherIds, cipherIds,
userVerification, userVerification,
assumeUserPresence, assumeUserPresence,
@@ -123,6 +125,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
try { try {
// Check if we can return the credential without user interaction // Check if we can return the credential without user interaction
await this.accountService.setShowHeader(false);
if (assumeUserPresence && cipherIds.length === 1 && !masterPasswordRepromptRequired) { if (assumeUserPresence && cipherIds.length === 1 && !masterPasswordRepromptRequired) {
this.logService.debug( this.logService.debug(
"shortcut - Assuming user presence and returning cipherId", "shortcut - Assuming user presence and returning cipherId",
@@ -136,22 +139,27 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
// make the cipherIds available to the UI. // make the cipherIds available to the UI.
this.availableCipherIdsSubject.next(cipherIds); this.availableCipherIdsSubject.next(cipherIds);
await this.showUi("/passkeys", this.windowObject.windowXy); await this.showUi("/fido2-assertion", this.windowObject.windowXy, false);
const chosenCipherResponse = await this.waitForUiChosenCipher(); const chosenCipherResponse = await this.waitForUiChosenCipher();
this.logService.debug("Received chosen cipher", chosenCipherResponse); this.logService.debug("Received chosen cipher", chosenCipherResponse);
return { return {
cipherId: chosenCipherResponse.cipherId, cipherId: chosenCipherResponse?.cipherId,
userVerified: chosenCipherResponse.userVerified, userVerified: chosenCipherResponse?.userVerified,
}; };
} finally { } finally {
// Make sure to clean up so the app is never stuck in modal mode? // Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setModalMode(false); await this.desktopSettingsService.setModalMode(false);
await this.accountService.setShowHeader(true);
} }
} }
async getRpId(): Promise<string> {
return firstValueFrom(this.rpId.pipe(filter((id) => id != null)));
}
confirmChosenCipher(cipherId: string, userVerified: boolean = false): void { confirmChosenCipher(cipherId: string, userVerified: boolean = false): void {
this.chosenCipherSubject.next({ cipherId, userVerified }); this.chosenCipherSubject.next({ cipherId, userVerified });
this.chosenCipherSubject.complete(); this.chosenCipherSubject.complete();
@@ -159,7 +167,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
private async waitForUiChosenCipher( private async waitForUiChosenCipher(
timeoutMs: number = 60000, timeoutMs: number = 60000,
): Promise<{ cipherId: string; userVerified: boolean } | undefined> { ): Promise<{ cipherId?: string; userVerified: boolean } | undefined> {
try { try {
return await lastValueFrom(this.chosenCipherSubject.pipe(timeout(timeoutMs))); return await lastValueFrom(this.chosenCipherSubject.pipe(timeout(timeoutMs)));
} catch { } catch {
@@ -174,7 +182,10 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
/** /**
* Notifies the Fido2UserInterfaceSession that the UI operations has completed and it can return to the OS. * Notifies the Fido2UserInterfaceSession that the UI operations has completed and it can return to the OS.
*/ */
notifyConfirmNewCredential(confirmed: boolean): void { notifyConfirmCreateCredential(confirmed: boolean, updatedCipher?: CipherView): void {
if (updatedCipher) {
this.updatedCipher = updatedCipher;
}
this.confirmCredentialSubject.next(confirmed); this.confirmCredentialSubject.next(confirmed);
this.confirmCredentialSubject.complete(); this.confirmCredentialSubject.complete();
} }
@@ -195,60 +206,79 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
async confirmNewCredential({ async confirmNewCredential({
credentialName, credentialName,
userName, userName,
userHandle,
userVerification, userVerification,
rpId, rpId,
}: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> { }: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
this.logService.warning( this.logService.debug(
"confirmNewCredential", "confirmNewCredential",
credentialName, credentialName,
userName, userName,
userHandle,
userVerification, userVerification,
rpId, rpId,
); );
this.rpId.next(rpId);
try { try {
await this.showUi("/passkeys", this.windowObject.windowXy); await this.showUi("/fido2-creation", this.windowObject.windowXy, false);
// Wait for the UI to wrap up // Wait for the UI to wrap up
const confirmation = await this.waitForUiNewCredentialConfirmation(); const confirmation = await this.waitForUiNewCredentialConfirmation();
if (!confirmation) { if (!confirmation) {
return { cipherId: undefined, userVerified: false }; return { cipherId: undefined, userVerified: false };
} }
// Create the credential
await this.createCredential({
credentialName,
userName,
rpId,
userHandle: "",
userVerification,
});
// wait for 10ms to help RXJS catch up(?) if (this.updatedCipher) {
// We sometimes get a race condition from this.createCredential not updating cipherService in time await this.updateCredential(this.updatedCipher);
//console.log("waiting 10ms.."); return { cipherId: this.updatedCipher.id, userVerified: userVerification };
//await new Promise((resolve) => setTimeout(resolve, 10)); } else {
//console.log("Just waited 10ms"); // Create the cipher
const createdCipher = await this.createCipher({
// Return the new cipher (this.createdCipher) credentialName,
return { cipherId: this.createdCipher.id, userVerified: userVerification }; userName,
rpId,
userHandle,
userVerification,
});
return { cipherId: createdCipher.id, userVerified: userVerification };
}
} finally { } finally {
// Make sure to clean up so the app is never stuck in modal mode? // Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setModalMode(false); await this.desktopSettingsService.setModalMode(false);
await this.accountService.setShowHeader(true);
} }
} }
private async showUi(route: string, position?: { x: number; y: number }): Promise<void> { private async hideUi(): Promise<void> {
await this.desktopSettingsService.setModalMode(false);
await this.router.navigate(["/"]);
}
private async showUi(
route: string,
position?: { x: number; y: number },
showTrafficButtons: boolean = false,
disableRedirect?: boolean,
): Promise<void> {
// Load the UI: // Load the UI:
await this.desktopSettingsService.setModalMode(true, position); await this.desktopSettingsService.setModalMode(true, showTrafficButtons, position);
await this.router.navigate(["/passkeys"]); await this.accountService.setShowHeader(showTrafficButtons);
await this.router.navigate([
route,
{
"disable-redirect": disableRedirect || null,
},
]);
} }
/** /**
* Can be called by the UI to create a new credential with user input etc. * Can be called by the UI to create a new cipher with user input etc.
* @param param0 * @param param0
*/ */
async createCredential({ credentialName, userName, rpId }: NewCredentialParams): Promise<Cipher> { async createCipher({ credentialName, userName, rpId }: NewCredentialParams): Promise<Cipher> {
// Store the passkey on a new cipher to avoid replacing something important // Store the passkey on a new cipher to avoid replacing something important
const cipher = new CipherView(); const cipher = new CipherView();
cipher.name = credentialName; cipher.name = credentialName;
@@ -267,32 +297,81 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
this.accountService.activeAccount$.pipe(map((a) => a?.id)), this.accountService.activeAccount$.pipe(map((a) => a?.id)),
); );
if (!activeUserId) {
throw new Error("No active user ID found!");
}
const encCipher = await this.cipherService.encrypt(cipher, activeUserId); const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
const createdCipher = await this.cipherService.createWithServer(encCipher);
this.createdCipher = createdCipher; try {
const createdCipher = await this.cipherService.createWithServer(encCipher);
return createdCipher; return createdCipher;
} catch {
throw new Error("Unable to create cipher");
}
}
async updateCredential(cipher: CipherView): Promise<void> {
this.logService.info("updateCredential");
await firstValueFrom(
this.accountService.activeAccount$.pipe(
map(async (a) => {
if (a) {
const encCipher = await this.cipherService.encrypt(cipher, a.id);
await this.cipherService.updateWithServer(encCipher);
}
}),
),
);
} }
async informExcludedCredential(existingCipherIds: string[]): Promise<void> { async informExcludedCredential(existingCipherIds: string[]): Promise<void> {
this.logService.warning("informExcludedCredential", existingCipherIds); this.logService.debug("informExcludedCredential", existingCipherIds);
// make the cipherIds available to the UI.
this.availableCipherIdsSubject.next(existingCipherIds);
await this.accountService.setShowHeader(false);
await this.showUi("/fido2-excluded", this.windowObject.windowXy, false);
} }
async ensureUnlockedVault(): Promise<void> { async ensureUnlockedVault(): Promise<void> {
this.logService.warning("ensureUnlockedVault"); this.logService.debug("ensureUnlockedVault");
const status = await firstValueFrom(this.authService.activeAccountStatus$); const status = await firstValueFrom(this.authService.activeAccountStatus$);
if (status !== AuthenticationStatus.Unlocked) { if (status !== AuthenticationStatus.Unlocked) {
throw new Error("Vault is not unlocked"); await this.showUi("/lock", this.windowObject.windowXy, true, true);
let status2: AuthenticationStatus;
try {
status2 = await lastValueFrom(
this.authService.activeAccountStatus$.pipe(
filter((s) => s === AuthenticationStatus.Unlocked),
take(1),
timeout(1000 * 60 * 5), // 5 minutes
),
);
} catch (error) {
this.logService.warning("Error while waiting for vault to unlock", error);
}
if (status2 === AuthenticationStatus.Unlocked) {
await this.router.navigate(["/"]);
}
if (status2 !== AuthenticationStatus.Unlocked) {
await this.hideUi();
throw new Error("Vault is not unlocked");
}
} }
} }
async informCredentialNotFound(): Promise<void> { async informCredentialNotFound(): Promise<void> {
this.logService.warning("informCredentialNotFound"); this.logService.debug("informCredentialNotFound");
} }
async close() { async close() {
this.logService.warning("close"); this.logService.debug("close");
} }
} }

View File

@@ -908,6 +908,12 @@
"unexpectedError": { "unexpectedError": {
"message": "An unexpected error has occurred." "message": "An unexpected error has occurred."
}, },
"unexpectedErrorShort": {
"message": "Unexpected error"
},
"closeThisBitwardenWindow": {
"message": "Close this Bitwarden window and try again."
},
"itemInformation": { "itemInformation": {
"message": "Item information" "message": "Item information"
}, },
@@ -3356,7 +3362,7 @@
"orgTrustWarning1": { "orgTrustWarning1": {
"message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint." "message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint."
}, },
"trustUser":{ "trustUser": {
"message": "Trust user" "message": "Trust user"
}, },
"inputRequired": { "inputRequired": {
@@ -3886,6 +3892,75 @@
"fileSavedToDevice": { "fileSavedToDevice": {
"message": "File saved to device. Manage from your device downloads." "message": "File saved to device. Manage from your device downloads."
}, },
"importantNotice": {
"message": "Important notice"
},
"setupTwoStepLogin": {
"message": "Set up two-step login"
},
"newDeviceVerificationNoticeContentPage1": {
"message": "Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025."
},
"newDeviceVerificationNoticeContentPage2": {
"message": "You can set up two-step login as an alternative way to protect your account or change your email to one you can access."
},
"remindMeLater": {
"message": "Remind me later"
},
"newDeviceVerificationNoticePageOneFormContent": {
"message": "Do you have reliable access to your email, $EMAIL$?",
"placeholders": {
"email": {
"content": "$1",
"example": "your_name@email.com"
}
}
},
"newDeviceVerificationNoticePageOneEmailAccessNo": {
"message": "No, I do not"
},
"newDeviceVerificationNoticePageOneEmailAccessYes": {
"message": "Yes, I can reliably access my email"
},
"turnOnTwoStepLogin": {
"message": "Turn on two-step login"
},
"changeAcctEmail": {
"message": "Change account email"
},
"passkeyLogin": {
"message": "Log in with passkey?"
},
"savePasskeyQuestion": {
"message": "Save passkey?"
},
"saveNewPasskey": {
"message": "Save as new login"
},
"savePasskeyNewLogin": {
"message": "Save passkey as new login"
},
"noMatchingLoginsForSite": {
"message": "No matching logins for this site"
},
"overwritePasskey": {
"message": "Overwrite passkey?"
},
"unableToSavePasskey": {
"message": "Unable to save passkey"
},
"alreadyContainsPasskey": {
"message": "This item already contains a passkey. Are you sure you want to overwrite the current passkey?"
},
"passkeyAlreadyExists": {
"message": "A passkey already exists for this application."
},
"applicationDoesNotSupportDuplicates": {
"message": "This application does not support duplicates."
},
"closeThisWindow": {
"message": "Close this window"
},
"allowScreenshots": { "allowScreenshots": {
"message": "Allow screen capture" "message": "Allow screen capture"
}, },
@@ -4244,8 +4319,8 @@
"andMoreFeatures": { "andMoreFeatures": {
"message": "And more!" "message": "And more!"
}, },
"planDescPremium": { "advancedOnlineSecurity": {
"message": "Complete online security" "message": "Advanced online security"
}, },
"upgradeToPremium": { "upgradeToPremium": {
"message": "Upgrade to Premium" "message": "Upgrade to Premium"

View File

@@ -53,9 +53,14 @@ export class TrayMain {
}, },
{ {
visible: isDev(), visible: isDev(),
label: "Fake Popup", label: "Fake Popup Select",
click: () => this.fakePopup(), click: () => this.fakePopup(),
}, },
{
visible: isDev(),
label: "Fake Popup Create",
click: () => this.fakePopupCreate(),
},
{ type: "separator" }, { type: "separator" },
{ {
label: this.i18nService.t("exit"), label: this.i18nService.t("exit"),
@@ -218,4 +223,8 @@ export class TrayMain {
private async fakePopup() { private async fakePopup() {
await this.messagingService.send("loadurl", { url: "/passkeys", modal: true }); await this.messagingService.send("loadurl", { url: "/passkeys", modal: true });
} }
private async fakePopupCreate() {
await this.messagingService.send("loadurl", { url: "/create-passkey", modal: true });
}
} }

View File

@@ -100,10 +100,10 @@ export class WindowMain {
applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]); 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. // 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(); this.win.hide();
} else if (!lastValue.isModalModeActive && newValue.isModalModeActive) { } else if (newValue.isModalModeActive) {
// Apply the popup modal styles // Apply the popup modal styles
this.logService.info("Applying popup modal styles", newValue.modalPosition); this.logService.info("Applying popup modal styles", newValue.modalPosition);
applyPopupModalStyles(this.win, newValue.modalPosition); applyPopupModalStyles(this.win, newValue.showTrafficButtons, newValue.modalPosition);
this.win.show(); this.win.show();
} }
}), }),
@@ -273,7 +273,7 @@ export class WindowMain {
this.win = new BrowserWindow({ this.win = new BrowserWindow({
width: this.windowStates[mainWindowSizeKey].width, width: this.windowStates[mainWindowSizeKey].width,
height: this.windowStates[mainWindowSizeKey].height, height: this.windowStates[mainWindowSizeKey].height,
minWidth: 680, minWidth: 600,
minHeight: 500, minHeight: 500,
x: this.windowStates[mainWindowSizeKey].x, x: this.windowStates[mainWindowSizeKey].x,
y: this.windowStates[mainWindowSizeKey].y, y: this.windowStates[mainWindowSizeKey].y,

View File

@@ -7,6 +7,11 @@ import { WindowMain } from "../../../main/window.main";
import { CommandDefinition } from "./command"; import { CommandDefinition } from "./command";
type BufferedMessage = {
channel: string;
data: any;
};
export type RunCommandParams<C extends CommandDefinition> = { export type RunCommandParams<C extends CommandDefinition> = {
namespace: C["namespace"]; namespace: C["namespace"];
command: C["name"]; command: C["name"];
@@ -17,12 +22,43 @@ export type RunCommandResult<C extends CommandDefinition> = C["output"];
export class NativeAutofillMain { export class NativeAutofillMain {
private ipcServer: autofill.IpcServer | null; private ipcServer: autofill.IpcServer | null;
private messageBuffer: BufferedMessage[] = [];
private listenerReady = false;
constructor( constructor(
private logService: LogService, private logService: LogService,
private windowMain: WindowMain, private windowMain: WindowMain,
) {} ) {}
/**
* Safely sends a message to the renderer, buffering it if the server isn't ready yet
*/
private safeSend(channel: string, data: any) {
if (this.listenerReady && this.windowMain.win?.webContents) {
this.windowMain.win.webContents.send(channel, data);
} else {
this.messageBuffer.push({ channel, data });
}
}
/**
* Flushes all buffered messages to the renderer
*/
private flushMessageBuffer() {
if (!this.windowMain.win?.webContents) {
this.logService.error("Cannot flush message buffer - window not available");
return;
}
this.logService.info(`Flushing ${this.messageBuffer.length} buffered messages`);
for (const { channel, data } of this.messageBuffer) {
this.windowMain.win.webContents.send(channel, data);
}
this.messageBuffer = [];
}
async init() { async init() {
ipcMain.handle( ipcMain.handle(
"autofill.runCommand", "autofill.runCommand",
@@ -43,7 +79,7 @@ export class NativeAutofillMain {
this.ipcServer.completeError(clientId, sequenceNumber, String(error)); this.ipcServer.completeError(clientId, sequenceNumber, String(error));
return; return;
} }
this.windowMain.win.webContents.send("autofill.passkeyRegistration", { this.safeSend("autofill.passkeyRegistration", {
clientId, clientId,
sequenceNumber, sequenceNumber,
request, request,
@@ -56,7 +92,7 @@ export class NativeAutofillMain {
this.ipcServer.completeError(clientId, sequenceNumber, String(error)); this.ipcServer.completeError(clientId, sequenceNumber, String(error));
return; return;
} }
this.windowMain.win.webContents.send("autofill.passkeyAssertion", { this.safeSend("autofill.passkeyAssertion", {
clientId, clientId,
sequenceNumber, sequenceNumber,
request, request,
@@ -69,28 +105,49 @@ export class NativeAutofillMain {
this.ipcServer.completeError(clientId, sequenceNumber, String(error)); this.ipcServer.completeError(clientId, sequenceNumber, String(error));
return; return;
} }
this.windowMain.win.webContents.send("autofill.passkeyAssertionWithoutUserInterface", { this.safeSend("autofill.passkeyAssertionWithoutUserInterface", {
clientId, clientId,
sequenceNumber, sequenceNumber,
request, request,
}); });
}, },
// NativeStatusCallback
(error, clientId, sequenceNumber, status) => {
if (error) {
this.logService.error("autofill.IpcServer.nativeStatus", error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
return;
}
this.safeSend("autofill.nativeStatus", {
clientId,
sequenceNumber,
status,
});
},
); );
ipcMain.on("autofill.listenerReady", () => {
this.listenerReady = true;
this.logService.info(
`Listener is ready, flushing ${this.messageBuffer.length} buffered messages`,
);
this.flushMessageBuffer();
});
ipcMain.on("autofill.completePasskeyRegistration", (event, data) => { ipcMain.on("autofill.completePasskeyRegistration", (event, data) => {
this.logService.warning("autofill.completePasskeyRegistration", data); this.logService.debug("autofill.completePasskeyRegistration", data);
const { clientId, sequenceNumber, response } = data; const { clientId, sequenceNumber, response } = data;
this.ipcServer.completeRegistration(clientId, sequenceNumber, response); this.ipcServer.completeRegistration(clientId, sequenceNumber, response);
}); });
ipcMain.on("autofill.completePasskeyAssertion", (event, data) => { ipcMain.on("autofill.completePasskeyAssertion", (event, data) => {
this.logService.warning("autofill.completePasskeyAssertion", data); this.logService.debug("autofill.completePasskeyAssertion", data);
const { clientId, sequenceNumber, response } = data; const { clientId, sequenceNumber, response } = data;
this.ipcServer.completeAssertion(clientId, sequenceNumber, response); this.ipcServer.completeAssertion(clientId, sequenceNumber, response);
}); });
ipcMain.on("autofill.completeError", (event, data) => { ipcMain.on("autofill.completeError", (event, data) => {
this.logService.warning("autofill.completeError", data); this.logService.debug("autofill.completeError", data);
const { clientId, sequenceNumber, error } = data; const { clientId, sequenceNumber, error } = data;
this.ipcServer.completeError(clientId, sequenceNumber, String(error)); this.ipcServer.completeError(clientId, sequenceNumber, String(error));
}); });

View File

@@ -14,5 +14,6 @@ export class WindowState {
export class ModalModeState { export class ModalModeState {
isModalModeActive: boolean; isModalModeActive: boolean;
showTrafficButtons?: boolean;
modalPosition?: { x: number; y: number }; // Modal position is often passed from the native UI modalPosition?: { x: number; y: number }; // Modal position is often passed from the native UI
} }

View File

@@ -3,15 +3,19 @@ import { BrowserWindow } from "electron";
import { WindowState } from "./models/domain/window-state"; import { WindowState } from "./models/domain/window-state";
// change as needed, however limited by mainwindow minimum size // change as needed, however limited by mainwindow minimum size
const popupWidth = 680; const popupWidth = 600;
const popupHeight = 500; const popupHeight = 600;
type Position = { x: number; y: number }; type Position = { x: number; y: number };
export function applyPopupModalStyles(window: BrowserWindow, position?: Position) { export function applyPopupModalStyles(
window: BrowserWindow,
showTrafficButtons: boolean = true,
position?: Position,
) {
window.unmaximize(); window.unmaximize();
window.setSize(popupWidth, popupHeight); window.setSize(popupWidth, popupHeight);
window.setWindowButtonVisibility?.(false); window.setWindowButtonVisibility?.(showTrafficButtons);
window.setMenuBarVisibility?.(false); window.setMenuBarVisibility?.(false);
window.setResizable(false); window.setResizable(false);
window.setAlwaysOnTop(true); window.setAlwaysOnTop(true);
@@ -40,7 +44,7 @@ function positionWindow(window: BrowserWindow, position?: Position) {
} }
export function applyMainWindowStyles(window: BrowserWindow, existingWindowState: WindowState) { export function applyMainWindowStyles(window: BrowserWindow, existingWindowState: WindowState) {
window.setMinimumSize(680, 500); window.setMinimumSize(popupWidth, popupHeight);
// need to guard against null/undefined values // need to guard against null/undefined values

View File

@@ -335,9 +335,14 @@ export class DesktopSettingsService {
* Sets the modal mode of the application. Setting this changes the windows-size and other properties. * 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. * @param value `true` if the application is in modal mode, `false` if it is not.
*/ */
async setModalMode(value: boolean, modalPosition?: { x: number; y: number }) { async setModalMode(
value: boolean,
showTrafficButtons?: boolean,
modalPosition?: { x: number; y: number },
) {
await this.modalModeState.update(() => ({ await this.modalModeState.update(() => ({
isModalModeActive: value, isModalModeActive: value,
showTrafficButtons,
modalPosition, modalPosition,
})); }));
} }

View File

@@ -21,7 +21,7 @@
<div> <div>
@if (premiumCardData$ | async; as premiumData) { @if (premiumCardData$ | async; as premiumData) {
<billing-pricing-card <billing-pricing-card
[tagline]="'planDescPremium' | i18n" [tagline]="'advancedOnlineSecurity' | i18n"
[price]="{ amount: premiumData.price, cadence: 'monthly' }" [price]="{ amount: premiumData.price, cadence: 'monthly' }"
[button]="{ type: 'primary', text: ('upgradeToPremium' | i18n) }" [button]="{ type: 'primary', text: ('upgradeToPremium' | i18n) }"
[features]="premiumData.features" [features]="premiumData.features"

View File

@@ -3060,7 +3060,7 @@
"message": "Upgrade your account to a Premium membership and unlock some great additional features." "message": "Upgrade your account to a Premium membership and unlock some great additional features."
}, },
"premiumSignUpStorage": { "premiumSignUpStorage": {
"message": "1 GB encrypted storage for file attachments." "message": "1 GB encrypted storage for file attachments."
}, },
"premiumSignUpStorageV2": { "premiumSignUpStorageV2": {
"message": "$SIZE$ encrypted storage for file attachments.", "message": "$SIZE$ encrypted storage for file attachments.",
@@ -11965,8 +11965,8 @@
"familiesMembership": { "familiesMembership": {
"message": "Families membership" "message": "Families membership"
}, },
"planDescPremium": { "advancedOnlineSecurity": {
"message": "Complete online security" "message": "Advanced online security"
}, },
"planDescFamiliesV2": { "planDescFamiliesV2": {
"message": "Premium security for your family" "message": "Premium security for your family"

View File

@@ -197,7 +197,7 @@ export default tseslint.config(
{ {
// uses negative lookahead to whitelist any class that doesn't start with "tw-" // uses negative lookahead to whitelist any class that doesn't start with "tw-"
// in other words: classnames that start with tw- must be valid TailwindCSS classes // in other words: classnames that start with tw- must be valid TailwindCSS classes
whitelist: ["(?!(tw)\\-).*"], whitelist: ["(?!(tw)\\-).*", "tw-app-region-drag", "tw-app-region-no-drag"],
}, },
], ],
"tailwindcss/enforces-negative-arbitrary-values": "error", "tailwindcss/enforces-negative-arbitrary-values": "error",
@@ -349,6 +349,7 @@ export default tseslint.config(
"file-selector", "file-selector",
"mfaType.*", "mfaType.*",
"filter.*", // Temporary until filters are migrated "filter.*", // Temporary until filters are migrated
"tw-app-region*", // Custom utility for native passkey modals
"tw-@container", "tw-@container",
], ],
}, },

View File

@@ -37,6 +37,8 @@ export class FakeAccountService implements AccountService {
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1); accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class // eslint-disable-next-line rxjs/no-exposed-subjects -- test class
accountVerifyDevicesSubject = new ReplaySubject<boolean>(1); accountVerifyDevicesSubject = new ReplaySubject<boolean>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
showHeaderSubject = new ReplaySubject<boolean>(1);
private _activeUserId: UserId; private _activeUserId: UserId;
get activeUserId() { get activeUserId() {
return this._activeUserId; return this._activeUserId;
@@ -55,6 +57,7 @@ export class FakeAccountService implements AccountService {
}), }),
); );
} }
showHeader$ = this.showHeaderSubject.asObservable();
get nextUpAccount$(): Observable<Account> { get nextUpAccount$(): Observable<Account> {
return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe( return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
map(([accounts, activeAccount, sortedUserIds]) => { map(([accounts, activeAccount, sortedUserIds]) => {
@@ -114,6 +117,10 @@ export class FakeAccountService implements AccountService {
this.accountsSubject.next(updated); this.accountsSubject.next(updated);
await this.mock.clean(userId); await this.mock.clean(userId);
} }
async setShowHeader(value: boolean): Promise<void> {
this.showHeaderSubject.next(value);
}
} }
const loggedOutInfo: AccountInfo = { const loggedOutInfo: AccountInfo = {

View File

@@ -47,6 +47,8 @@ export abstract class AccountService {
abstract sortedUserIds$: Observable<UserId[]>; abstract sortedUserIds$: Observable<UserId[]>;
/** Next account that is not the current active account */ /** Next account that is not the current active account */
abstract nextUpAccount$: Observable<Account>; abstract nextUpAccount$: Observable<Account>;
/** Observable to display the header */
abstract showHeader$: Observable<boolean>;
/** /**
* Updates the `accounts$` observable with the new account data. * Updates the `accounts$` observable with the new account data.
* *
@@ -100,6 +102,11 @@ export abstract class AccountService {
* @param lastActivity * @param lastActivity
*/ */
abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>; abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>;
/**
* Show the account switcher.
* @param value
*/
abstract setShowHeader(visible: boolean): Promise<void>;
} }
export abstract class InternalAccountService extends AccountService { export abstract class InternalAccountService extends AccountService {

View File

@@ -429,6 +429,16 @@ describe("accountService", () => {
}, },
); );
}); });
describe("setShowHeader", () => {
it("should update _showHeader$ when setShowHeader is called", async () => {
expect(sut["_showHeader$"].value).toBe(true);
await sut.setShowHeader(false);
expect(sut["_showHeader$"].value).toBe(false);
});
});
}); });
}); });

View File

@@ -6,6 +6,7 @@ import {
distinctUntilChanged, distinctUntilChanged,
shareReplay, shareReplay,
combineLatest, combineLatest,
BehaviorSubject,
Observable, Observable,
switchMap, switchMap,
filter, filter,
@@ -84,6 +85,7 @@ export const getOptionalUserId = map<Account | null, UserId | null>(
export class AccountServiceImplementation implements InternalAccountService { export class AccountServiceImplementation implements InternalAccountService {
private accountsState: GlobalState<Record<UserId, AccountInfo>>; private accountsState: GlobalState<Record<UserId, AccountInfo>>;
private activeAccountIdState: GlobalState<UserId | undefined>; private activeAccountIdState: GlobalState<UserId | undefined>;
private _showHeader$ = new BehaviorSubject<boolean>(true);
accounts$: Observable<Record<UserId, AccountInfo>>; accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<Account | null>; activeAccount$: Observable<Account | null>;
@@ -91,6 +93,7 @@ export class AccountServiceImplementation implements InternalAccountService {
accountVerifyNewDeviceLogin$: Observable<boolean>; accountVerifyNewDeviceLogin$: Observable<boolean>;
sortedUserIds$: Observable<UserId[]>; sortedUserIds$: Observable<UserId[]>;
nextUpAccount$: Observable<Account>; nextUpAccount$: Observable<Account>;
showHeader$ = this._showHeader$.asObservable();
constructor( constructor(
private messagingService: MessagingService, private messagingService: MessagingService,
@@ -262,6 +265,10 @@ export class AccountServiceImplementation implements InternalAccountService {
} }
} }
async setShowHeader(visible: boolean): Promise<void> {
this._showHeader$.next(visible);
}
private async setAccountInfo(userId: UserId, update: Partial<AccountInfo>): Promise<void> { private async setAccountInfo(userId: UserId, update: Partial<AccountInfo>): Promise<void> {
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo { function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
return { ...oldAccountInfo, ...update }; return { ...oldAccountInfo, ...update };

View File

@@ -250,7 +250,7 @@ describe("DefaultSubscriptionPricingService", () => {
return "Custom"; return "Custom";
// Plan descriptions // Plan descriptions
case "planDescPremium": case "advancedOnlineSecurity":
return "Premium plan description"; return "Premium plan description";
case "planDescFamiliesV2": case "planDescFamiliesV2":
return "Families plan description"; return "Families plan description";
@@ -397,7 +397,7 @@ describe("DefaultSubscriptionPricingService", () => {
}); });
expect(i18nService.t).toHaveBeenCalledWith("premium"); expect(i18nService.t).toHaveBeenCalledWith("premium");
expect(i18nService.t).toHaveBeenCalledWith("planDescPremium"); expect(i18nService.t).toHaveBeenCalledWith("advancedOnlineSecurity");
expect(i18nService.t).toHaveBeenCalledWith("planNameFamilies"); expect(i18nService.t).toHaveBeenCalledWith("planNameFamilies");
expect(i18nService.t).toHaveBeenCalledWith("planDescFamiliesV2"); expect(i18nService.t).toHaveBeenCalledWith("planDescFamiliesV2");
expect(i18nService.t).toHaveBeenCalledWith("builtInAuthenticator"); expect(i18nService.t).toHaveBeenCalledWith("builtInAuthenticator");

View File

@@ -127,7 +127,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
map((premiumPrices) => ({ map((premiumPrices) => ({
id: PersonalSubscriptionPricingTierIds.Premium, id: PersonalSubscriptionPricingTierIds.Premium,
name: this.i18nService.t("premium"), name: this.i18nService.t("premium"),
description: this.i18nService.t("planDescPremium"), description: this.i18nService.t("advancedOnlineSecurity"),
availableCadences: [SubscriptionCadenceIds.Annually], availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: { passwordManager: {
type: "standalone", type: "standalone",

View File

@@ -62,6 +62,7 @@ export enum FeatureFlag {
AutofillConfirmation = "pm-25083-autofill-confirm-from-search", AutofillConfirmation = "pm-25083-autofill-confirm-from-search",
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium", RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
/* Platform */ /* Platform */
IpcChannelFramework = "ipc-channel-framework", IpcChannelFramework = "ipc-channel-framework",
@@ -119,6 +120,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AutofillConfirmation]: FALSE, [FeatureFlag.AutofillConfirmation]: FALSE,
[FeatureFlag.RiskInsightsForPremium]: FALSE, [FeatureFlag.RiskInsightsForPremium]: FALSE,
[FeatureFlag.VaultLoadingSkeletons]: FALSE, [FeatureFlag.VaultLoadingSkeletons]: FALSE,
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
/* Auth */ /* Auth */
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE, [FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,

View File

@@ -138,7 +138,7 @@ export interface Fido2AuthenticatorGetAssertionParams {
rpId: string; rpId: string;
/** The hash of the serialized client data, provided by the client. */ /** The hash of the serialized client data, provided by the client. */
hash: BufferSource; hash: BufferSource;
allowCredentialDescriptorList: PublicKeyCredentialDescriptor[]; allowCredentialDescriptorList?: PublicKeyCredentialDescriptor[];
/** The effective user verification requirement for assertion, a Boolean value provided by the client. */ /** The effective user verification requirement for assertion, a Boolean value provided by the client. */
requireUserVerification: boolean; requireUserVerification: boolean;
/** The constant Boolean value true. It is included here as a pseudo-parameter to simplify applying this abstract authenticator model to implementations that may wish to make a test of user presence optional although WebAuthn does not. */ /** The constant Boolean value true. It is included here as a pseudo-parameter to simplify applying this abstract authenticator model to implementations that may wish to make a test of user presence optional although WebAuthn does not. */

View File

@@ -95,7 +95,7 @@ export abstract class Fido2UserInterfaceSession {
*/ */
abstract confirmNewCredential( abstract confirmNewCredential(
params: NewCredentialParams, params: NewCredentialParams,
): Promise<{ cipherId: string; userVerified: boolean }>; ): Promise<{ cipherId?: string; userVerified: boolean }>;
/** /**
* Make sure that the vault is unlocked. * Make sure that the vault is unlocked.

View File

@@ -1,3 +1,9 @@
import { mock } from "jest-mock-extended";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
import { Fido2Utils } from "./fido2-utils"; import { Fido2Utils } from "./fido2-utils";
describe("Fido2 Utils", () => { describe("Fido2 Utils", () => {
@@ -67,4 +73,62 @@ describe("Fido2 Utils", () => {
expect(expectedArray).toBeNull(); expect(expectedArray).toBeNull();
}); });
}); });
describe("cipherHasNoOtherPasskeys(...)", () => {
const emptyPasskeyCipher = mock<CipherView>({
id: "id-5",
localData: { lastUsedDate: 222 },
name: "name-5",
type: CipherType.Login,
login: {
username: "username-5",
password: "password",
uri: "https://example.com",
fido2Credentials: [],
},
});
const passkeyCipher = mock<CipherView>({
id: "id-5",
localData: { lastUsedDate: 222 },
name: "name-5",
type: CipherType.Login,
login: {
username: "username-5",
password: "password",
uri: "https://example.com",
fido2Credentials: [
mock<Fido2CredentialView>({
credentialId: "credential-id",
rpName: "credential-name",
userHandle: "user-handle-1",
userName: "credential-username",
rpId: "jest-testing-website.com",
}),
mock<Fido2CredentialView>({
credentialId: "credential-id",
rpName: "credential-name",
userHandle: "user-handle-2",
userName: "credential-username",
rpId: "jest-testing-website.com",
}),
],
},
});
it("should return true when there is no userHandle", () => {
const userHandle = "user-handle-1";
expect(Fido2Utils.cipherHasNoOtherPasskeys(emptyPasskeyCipher, userHandle)).toBeTruthy();
});
it("should return true when userHandle matches", () => {
const userHandle = "user-handle-1";
expect(Fido2Utils.cipherHasNoOtherPasskeys(passkeyCipher, userHandle)).toBeTruthy();
});
it("should return false when userHandle doesn't match", () => {
const userHandle = "testing";
expect(Fido2Utils.cipherHasNoOtherPasskeys(passkeyCipher, userHandle)).toBeFalsy();
});
});
}); });

View File

@@ -1,3 +1,5 @@
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
import type { import type {
AssertCredentialResult, AssertCredentialResult,
@@ -111,4 +113,16 @@ export class Fido2Utils {
return output; return output;
} }
/**
* This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle
* @param userHandle
*/
static cipherHasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean {
if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) {
return true;
}
return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle);
}
} }

View File

@@ -1,28 +1,63 @@
import { guidToRawFormat } from "./guid-utils"; import { guidToRawFormat, guidToStandardFormat } from "./guid-utils";
const workingExamples: [string, Uint8Array][] = [
[
"00000000-0000-0000-0000-000000000000",
new Uint8Array([
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00,
]),
],
[
"08d70b74-e9f5-4522-a425-e5dcd40107e7",
new Uint8Array([
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
0xe7,
]),
],
];
describe("guid-utils", () => { describe("guid-utils", () => {
describe("guidToRawFormat", () => { describe("guidToRawFormat", () => {
it.each(workingExamples)(
"returns UUID in binary format when given a valid UUID string",
(input, expected) => {
const result = guidToRawFormat(input);
expect(result).toEqual(expected);
},
);
it.each([ it.each([
[ "invalid",
"00000000-0000-0000-0000-000000000000", "",
[ "",
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, "00000000-0000-0000-0000-0000000000000000",
0x00, "00000000-0000-0000-0000-000000",
], ])("throws an error when given an invalid UUID string", (input) => {
"08d70b74-e9f5-4522-a425-e5dcd40107e7", expect(() => guidToRawFormat(input)).toThrow(TypeError);
[
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
0xe7,
],
],
])("returns UUID in binary format when given a valid UUID string", (input, expected) => {
const result = guidToRawFormat(input);
expect(result).toEqual(new Uint8Array(expected));
}); });
});
it("throws an error when given an invalid UUID string", () => { describe("guidToStandardFormat", () => {
expect(() => guidToRawFormat("invalid")).toThrow(TypeError); it.each(workingExamples)(
"returns UUID in standard format when given a valid UUID array buffer",
(expected, input) => {
const result = guidToStandardFormat(input);
expect(result).toEqual(expected);
},
);
it.each([
new Uint8Array(),
new Uint8Array([]),
new Uint8Array([
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7,
]),
])("throws an error when given an invalid UUID array buffer", (input) => {
expect(() => guidToStandardFormat(input)).toThrow(TypeError);
}); });
}); });
}); });

View File

@@ -53,6 +53,10 @@ export function guidToRawFormat(guid: string) {
/** Convert raw 16 byte array to standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID. */ /** Convert raw 16 byte array to standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID. */
export function guidToStandardFormat(bufferSource: BufferSource) { export function guidToStandardFormat(bufferSource: BufferSource) {
if (bufferSource.byteLength !== 16) {
throw TypeError("BufferSource length is invalid");
}
const arr = const arr =
bufferSource instanceof ArrayBuffer bufferSource instanceof ArrayBuffer
? new Uint8Array(bufferSource) ? new Uint8Array(bufferSource)

View File

@@ -68,6 +68,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
/** When true, will override the match strategy for the cipher if it is Never. */ /** When true, will override the match strategy for the cipher if it is Never. */
overrideNeverMatchStrategy?: true, overrideNeverMatchStrategy?: true,
): Promise<CipherView[]>; ): Promise<CipherView[]>;
abstract getAllDecryptedForIds(userId: UserId, ids: string[]): Promise<CipherView[]>;
abstract filterCiphersForUrl<C extends CipherViewLike = CipherView>( abstract filterCiphersForUrl<C extends CipherViewLike = CipherView>(
ciphers: C[], ciphers: C[],
url: string, url: string,

View File

@@ -165,7 +165,9 @@ export class CipherService implements CipherServiceAbstraction {
}), }),
switchMap(async (ciphers) => { switchMap(async (ciphers) => {
const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false); const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false);
await this.setFailedDecryptedCiphers(failures, userId); void this.setFailedDecryptedCiphers(failures, userId);
// Trigger full decryption and indexing in background
void this.getAllDecrypted(userId);
return decrypted; return decrypted;
}), }),
tap((decrypted) => { tap((decrypted) => {
@@ -634,6 +636,15 @@ export class CipherService implements CipherServiceAbstraction {
); );
} }
async getAllDecryptedForIds(userId: UserId, ids: string[]): Promise<CipherView[]> {
return firstValueFrom(
this.cipherViews$(userId).pipe(
filter((ciphers) => ciphers != null),
map((ciphers) => ciphers.filter((cipher) => ids.includes(cipher.id))),
),
);
}
async filterCiphersForUrl<C extends CipherViewLike>( async filterCiphersForUrl<C extends CipherViewLike>(
ciphers: C[], ciphers: C[],
url: string, url: string,

View File

@@ -3,7 +3,7 @@
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-100 tw-bg-background tw-text-main" class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-100 tw-bg-background tw-text-main"
[ngClass]="[ [ngClass]="[
width, width,
isDrawer ? 'tw-h-screen tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg', isDrawer ? 'tw-h-full tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg',
]" ]"
cdkTrapFocus cdkTrapFocus
cdkTrapFocusAutoCapture cdkTrapFocusAutoCapture

View File

@@ -104,7 +104,7 @@ export class DialogComponent {
// `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header // `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header
const baseClasses = ["tw-flex", "tw-flex-col", "tw-w-screen"]; const baseClasses = ["tw-flex", "tw-flex-col", "tw-w-screen"];
const sizeClasses = this.dialogRef?.isDrawer const sizeClasses = this.dialogRef?.isDrawer
? ["tw-min-h-screen", "md:tw-w-[23rem]"] ? ["tw-h-full", "md:tw-w-[23rem]"]
: ["md:tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"]; : ["md:tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"];
const animationClasses = const animationClasses =

View File

@@ -12,7 +12,7 @@ import { hasScrolledFrom } from "../utils/has-scrolled-from";
imports: [], imports: [],
host: { host: {
class: class:
"tw-p-4 tw-pt-0 tw-block tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200", "tw-p-4 tw-pt-0 tw-flex-1 tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200",
"[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top", "[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top",
}, },
hostDirectives: [ hostDirectives: [

View File

@@ -1,7 +1,7 @@
<ng-container *cdkPortal> <ng-container *cdkPortal>
<section <section
[attr.role]="role()" [attr.role]="role()"
class="tw-w-[23rem] tw-sticky tw-top-0 tw-h-screen tw-flex tw-flex-col tw-overflow-auto tw-border-0 tw-border-l tw-border-solid tw-border-secondary-300 tw-bg-background" class="tw-w-[23rem] tw-sticky tw-top-0 tw-h-full tw-flex tw-flex-col tw-overflow-auto tw-border-0 tw-border-l tw-border-solid tw-border-secondary-300 tw-bg-background"
> >
<ng-content></ng-content> <ng-content></ng-content>
</section> </section>

View File

@@ -1,2 +1,2 @@
export * from "./icon-button.module"; export * from "./icon-button.module";
export { BitIconButtonComponent } from "./icon-button.component"; export * from "./icon-button.component";

View File

@@ -39,6 +39,9 @@ export * from "./section";
export * from "./select"; export * from "./select";
export * from "./shared/compact-mode.service"; export * from "./shared/compact-mode.service";
export * from "./skeleton"; export * from "./skeleton";
export * from "./spinner";
export * from "./stepper";
export * from "./switch";
export * from "./table"; export * from "./table";
export * from "./tabs"; export * from "./tabs";
export * from "./toast"; export * from "./toast";
@@ -46,5 +49,3 @@ export * from "./toggle-group";
export * from "./tooltip"; export * from "./tooltip";
export * from "./typography"; export * from "./typography";
export * from "./utils"; export * from "./utils";
export * from "./stepper";
export * from "./switch";

View File

@@ -1,6 +1,6 @@
@let mainContentId = "main-content"; @let mainContentId = "main-content";
<div class="tw-flex tw-w-full"> <div class="tw-flex tw-size-full">
<div class="tw-flex tw-w-full" cdkTrapFocus> <div class="tw-flex tw-size-full" cdkTrapFocus>
<div <div
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto" class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
> >
@@ -23,7 +23,7 @@
[id]="mainContentId" [id]="mainContentId"
tabindex="-1" tabindex="-1"
bitScrollLayoutHost bitScrollLayoutHost
class="tw-overflow-auto tw-max-h-screen tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container" class="tw-overflow-auto tw-max-h-full tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container"
> >
<!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ --> <!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ -->
<ng-content></ng-content> <ng-content></ng-content>
@@ -45,7 +45,7 @@
</div> </div>
} }
</div> </div>
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-screen md:tw-w-auto"> <div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-full md:tw-w-auto">
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template> <ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
</div> </div>
</div> </div>

View File

@@ -29,6 +29,7 @@ import { ScrollLayoutHostDirective } from "./scroll-layout.directive";
], ],
host: { host: {
"(document:keydown.tab)": "handleKeydown($event)", "(document:keydown.tab)": "handleKeydown($event)",
class: "tw-block tw-h-screen",
}, },
hostDirectives: [DrawerHostDirective], hostDirectives: [DrawerHostDirective],
}) })

View File

@@ -7,7 +7,7 @@
) { ) {
<nav <nav
id="bit-side-nav" id="bit-side-nav"
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-screen tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none" class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none"
[ngClass]="{ 'tw-w-60': data.open }" [ngClass]="{ 'tw-w-60': data.open }"
[ngStyle]=" [ngStyle]="
variant() === 'secondary' && { variant() === 'secondary' && {

View File

@@ -17,6 +17,9 @@ export type SideNavVariant = "primary" | "secondary";
selector: "bit-side-nav", selector: "bit-side-nav",
templateUrl: "side-nav.component.html", templateUrl: "side-nav.component.html",
imports: [CommonModule, CdkTrapFocus, NavDividerComponent, BitIconButtonComponent, I18nPipe], imports: [CommonModule, CdkTrapFocus, NavDividerComponent, BitIconButtonComponent, I18nPipe],
host: {
class: "tw-block tw-h-full",
},
}) })
export class SideNavComponent { export class SideNavComponent {
readonly variant = input<SideNavVariant>("primary"); readonly variant = input<SideNavVariant>("primary");

View File

@@ -168,6 +168,18 @@
text-align: unset; text-align: unset;
} }
/**
* tw-app-region-drag and tw-app-region-no-drag are used for Electron window dragging behavior
* These will replace direct -webkit-app-region usage as part of the migration to Tailwind CSS
*/
.tw-app-region-drag {
-webkit-app-region: drag;
}
.tw-app-region-no-drag {
-webkit-app-region: no-drag;
}
/** /**
* Bootstrap uses z-index: 1050 for modals, dialogs and drag-and-drop previews should appear above them. * Bootstrap uses z-index: 1050 for modals, dialogs and drag-and-drop previews should appear above them.
* When bootstrap is removed, test if these styles are still needed and that overlays display properly over other content. * When bootstrap is removed, test if these styles are still needed and that overlays display properly over other content.

View File

@@ -2,7 +2,7 @@ import { DebugElement } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { By } from "@angular/platform-browser"; import { By } from "@angular/platform-browser";
import { Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { firstValueFrom, interval, map, of, takeWhile, timeout } from "rxjs"; import { firstValueFrom, interval, map, of, takeWhile, timeout } from "rxjs";
import { ZXCVBNResult } from "zxcvbn"; import { ZXCVBNResult } from "zxcvbn";
@@ -94,6 +94,13 @@ describe("LockComponent", () => {
const mockBroadcasterService = mock<BroadcasterService>(); const mockBroadcasterService = mock<BroadcasterService>();
const mockEncryptedMigrator = mock<EncryptedMigrator>(); const mockEncryptedMigrator = mock<EncryptedMigrator>();
const mockConfigService = mock<ConfigService>(); const mockConfigService = mock<ConfigService>();
const mockActivatedRoute = {
snapshot: {
paramMap: {
get: jest.fn().mockReturnValue(null), // return null for 'disable-redirect' param
},
},
};
beforeEach(async () => { beforeEach(async () => {
jest.resetAllMocks(); jest.resetAllMocks();
@@ -151,6 +158,7 @@ describe("LockComponent", () => {
{ provide: LockComponentService, useValue: mockLockComponentService }, { provide: LockComponentService, useValue: mockLockComponentService },
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService }, { provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
{ provide: BroadcasterService, useValue: mockBroadcasterService }, { provide: BroadcasterService, useValue: mockBroadcasterService },
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: EncryptedMigrator, useValue: mockEncryptedMigrator }, { provide: EncryptedMigrator, useValue: mockEncryptedMigrator },
{ provide: ConfigService, useValue: mockConfigService }, { provide: ConfigService, useValue: mockConfigService },
], ],
@@ -467,6 +475,14 @@ describe("LockComponent", () => {
component.clientType = clientType; component.clientType = clientType;
mockLockComponentService.getPreviousUrl.mockReturnValue(null); mockLockComponentService.getPreviousUrl.mockReturnValue(null);
jest.spyOn(component as any, "doContinue").mockImplementation(async () => {
await mockBiometricStateService.resetUserPromptCancelled();
mockMessagingService.send("unlocked");
await mockSyncService.fullSync(false);
await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
await mockRouter.navigate([navigateUrl]);
});
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
assertUnlocked(); assertUnlocked();
@@ -478,6 +494,16 @@ describe("LockComponent", () => {
component.shouldClosePopout = true; component.shouldClosePopout = true;
mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension); mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension);
jest.spyOn(component as any, "doContinue").mockImplementation(async () => {
await mockBiometricStateService.resetUserPromptCancelled();
mockMessagingService.send("unlocked");
await mockSyncService.fullSync(false);
await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(
component.activeAccount!.id,
);
mockLockComponentService.closeBrowserExtensionPopout();
});
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
assertUnlocked(); assertUnlocked();
@@ -611,6 +637,32 @@ describe("LockComponent", () => {
])( ])(
"should unlock and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy set during user verification by master password", "should unlock and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy set during user verification by master password",
async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => { async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => {
jest.spyOn(component as any, "doContinue").mockImplementation(async () => {
await mockBiometricStateService.resetUserPromptCancelled();
mockMessagingService.send("unlocked");
if (masterPasswordPolicyOptions?.enforceOnLogin) {
const passwordStrengthResult = mockPasswordStrengthService.getPasswordStrength(
masterPassword,
component.activeAccount!.email,
);
const evaluated = mockPolicyService.evaluateMasterPassword(
passwordStrengthResult.score,
masterPassword,
masterPasswordPolicyOptions,
);
if (!evaluated) {
await mockMasterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.WeakMasterPassword,
userId,
);
}
}
await mockSyncService.fullSync(false);
await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
});
mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue({ mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue({
...masterPasswordVerificationResponse, ...masterPasswordVerificationResponse,
policyOptions: policyOptions:
@@ -725,6 +777,14 @@ describe("LockComponent", () => {
component.clientType = clientType; component.clientType = clientType;
mockLockComponentService.getPreviousUrl.mockReturnValue(null); mockLockComponentService.getPreviousUrl.mockReturnValue(null);
jest.spyOn(component as any, "doContinue").mockImplementation(async () => {
await mockBiometricStateService.resetUserPromptCancelled();
mockMessagingService.send("unlocked");
await mockSyncService.fullSync(false);
await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
await mockRouter.navigate([navigateUrl]);
});
await component.unlockViaMasterPassword(); await component.unlockViaMasterPassword();
assertUnlocked(); assertUnlocked();
@@ -736,6 +796,16 @@ describe("LockComponent", () => {
component.shouldClosePopout = true; component.shouldClosePopout = true;
mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension); mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension);
jest.spyOn(component as any, "doContinue").mockImplementation(async () => {
await mockBiometricStateService.resetUserPromptCancelled();
mockMessagingService.send("unlocked");
await mockSyncService.fullSync(false);
await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(
component.activeAccount!.id,
);
mockLockComponentService.closeBrowserExtensionPopout();
});
await component.unlockViaMasterPassword(); await component.unlockViaMasterPassword();
assertUnlocked(); assertUnlocked();

View File

@@ -1,7 +1,7 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Component, NgZone, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router } from "@angular/router"; import { Router, ActivatedRoute } from "@angular/router";
import { import {
BehaviorSubject, BehaviorSubject,
filter, filter,
@@ -160,6 +160,7 @@ export class LockComponent implements OnInit, OnDestroy {
private keyService: KeyService, private keyService: KeyService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private router: Router, private router: Router,
private activatedRoute: ActivatedRoute,
private dialogService: DialogService, private dialogService: DialogService,
private messagingService: MessagingService, private messagingService: MessagingService,
private biometricStateService: BiometricStateService, private biometricStateService: BiometricStateService,
@@ -710,7 +711,13 @@ export class LockComponent implements OnInit, OnDestroy {
} }
// determine success route based on client type // determine success route based on client type
if (this.clientType != null) { // The disable-redirect parameter allows callers to prevent automatic navigation after unlock,
// useful when the lock component is used in contexts where custom post-unlock behavior is needed
// such as passkey modals.
if (
this.clientType != null &&
this.activatedRoute.snapshot.paramMap.get("disable-redirect") === null
) {
const successRoute = clientTypeToSuccessRouteRecord[this.clientType]; const successRoute = clientTypeToSuccessRouteRecord[this.clientType];
await this.router.navigate([successRoute]); await this.router.navigate([successRoute]);
} }

25
package-lock.json generated
View File

@@ -87,7 +87,7 @@
"@electron/rebuild": "4.0.1", "@electron/rebuild": "4.0.1",
"@eslint/compat": "2.0.0", "@eslint/compat": "2.0.0",
"@lit-labs/signals": "0.1.2", "@lit-labs/signals": "0.1.2",
"@ngtools/webpack": "20.3.11", "@ngtools/webpack": "20.3.12",
"@storybook/addon-a11y": "9.1.16", "@storybook/addon-a11y": "9.1.16",
"@storybook/addon-designs": "9.0.0-next.3", "@storybook/addon-designs": "9.0.0-next.3",
"@storybook/addon-docs": "9.1.16", "@storybook/addon-docs": "9.1.16",
@@ -1029,23 +1029,6 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/@angular-devkit/build-angular/node_modules/@ngtools/webpack": {
"version": "20.3.12",
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-20.3.12.tgz",
"integrity": "sha512-ePuofHOtbgvEq2t+hcmL30s4q9HQ/nv9ABwpLiELdVIObcWUnrnizAvM7hujve/9CQL6gRCeEkxPLPS4ZrK9AQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
"yarn": ">= 1.13.0"
},
"peerDependencies": {
"@angular/compiler-cli": "^20.0.0",
"typescript": ">=5.8 <6.0",
"webpack": "^5.54.0"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/ansi-regex": { "node_modules/@angular-devkit/build-angular/node_modules/ansi-regex": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
@@ -9002,9 +8985,9 @@
} }
}, },
"node_modules/@ngtools/webpack": { "node_modules/@ngtools/webpack": {
"version": "20.3.11", "version": "20.3.12",
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-20.3.11.tgz", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-20.3.12.tgz",
"integrity": "sha512-c2/66tObP9YevCt7jyhwiGifS8ldfce6vYQ63Wwj8tlXSSutHk8+3VEQmbW3wW1JH7+0aNf3kF+pA97EbGj6QA==", "integrity": "sha512-ePuofHOtbgvEq2t+hcmL30s4q9HQ/nv9ABwpLiELdVIObcWUnrnizAvM7hujve/9CQL6gRCeEkxPLPS4ZrK9AQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {

View File

@@ -49,7 +49,7 @@
"@electron/rebuild": "4.0.1", "@electron/rebuild": "4.0.1",
"@eslint/compat": "2.0.0", "@eslint/compat": "2.0.0",
"@lit-labs/signals": "0.1.2", "@lit-labs/signals": "0.1.2",
"@ngtools/webpack": "20.3.11", "@ngtools/webpack": "20.3.12",
"@storybook/addon-a11y": "9.1.16", "@storybook/addon-a11y": "9.1.16",
"@storybook/addon-designs": "9.0.0-next.3", "@storybook/addon-designs": "9.0.0-next.3",
"@storybook/addon-docs": "9.1.16", "@storybook/addon-docs": "9.1.16",