1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

Merge branch 'main' into auth/pm-9115/implement-view-data-persistence-in-2FA-flows

This commit is contained in:
Alec Rippberger
2025-03-24 11:28:51 -05:00
committed by GitHub
33 changed files with 959 additions and 300 deletions

View File

@@ -186,7 +186,7 @@
"message": "Salin catatan"
},
"copy": {
"message": "Copy",
"message": "Salin",
"description": "Copy to clipboard"
},
"fill": {
@@ -380,7 +380,7 @@
"message": "Sunting Folder"
},
"editFolderWithName": {
"message": "Edit folder: $FOLDERNAME$",
"message": "Sunting folder: $FOLDERNAME$",
"placeholders": {
"foldername": {
"content": "$1",
@@ -462,16 +462,16 @@
"message": "Buat frasa sandi"
},
"passwordGenerated": {
"message": "Password generated"
"message": "Kata sandi dibuat"
},
"passphraseGenerated": {
"message": "Passphrase generated"
"message": "Frasa sandi dibuat"
},
"usernameGenerated": {
"message": "Username generated"
"message": "Nama pengguna dibuat"
},
"emailGenerated": {
"message": "Email generated"
"message": "Surel dibuat"
},
"regeneratePassword": {
"message": "Buat Ulang Kata Sandi"
@@ -653,7 +653,7 @@
"message": "Peramban Anda tidak mendukung menyalin clipboard dengan mudah. Salin secara manual."
},
"verifyYourIdentity": {
"message": "Verify your identity"
"message": "Verifikasikan identitas Anda"
},
"weDontRecognizeThisDevice": {
"message": "We don't recognize this device. Enter the code sent to your email to verify your identity."
@@ -905,7 +905,7 @@
"message": "Tidak"
},
"location": {
"message": "Location"
"message": "Lokasi"
},
"unexpectedError": {
"message": "Terjadi kesalahan yang tak diduga."
@@ -2461,7 +2461,7 @@
"message": "Change this in settings"
},
"change": {
"message": "Change"
"message": "Ubah"
},
"changeButtonTitle": {
"message": "Change password - $ITEMNAME$",
@@ -4784,7 +4784,7 @@
"message": "Text Sends"
},
"accountActions": {
"message": "Account actions"
"message": "Tindakan akun"
},
"showNumberOfAutofillSuggestions": {
"message": "Show number of login autofill suggestions on extension icon"
@@ -4793,22 +4793,22 @@
"message": "Show quick copy actions on Vault"
},
"systemDefault": {
"message": "System default"
"message": "Baku sistem"
},
"enterprisePolicyRequirementsApplied": {
"message": "Enterprise policy requirements have been applied to this setting"
"message": "Persyaratan kebijakan perusahaan telah diterapkan ke pengaturan ini"
},
"sshPrivateKey": {
"message": "Private key"
"message": "Kunci privat"
},
"sshPublicKey": {
"message": "Public key"
"message": "Kunci publik"
},
"sshFingerprint": {
"message": "Fingerprint"
"message": "Sidik jari"
},
"sshKeyAlgorithm": {
"message": "Key type"
"message": "Tipe kunci"
},
"sshKeyAlgorithmED25519": {
"message": "ED25519"
@@ -4823,7 +4823,7 @@
"message": "RSA 4096-Bit"
},
"retry": {
"message": "Retry"
"message": "Coba lagi"
},
"vaultCustomTimeoutMinimum": {
"message": "Minimum custom timeout is 1 minute."
@@ -5075,16 +5075,16 @@
}
},
"newDeviceVerificationNoticePageOneEmailAccessNo": {
"message": "No, I do not"
"message": "Tidak"
},
"newDeviceVerificationNoticePageOneEmailAccessYes": {
"message": "Yes, I can reliably access my email"
"message": "Ya, saya dapat mengakses surel saya secara handla"
},
"turnOnTwoStepLogin": {
"message": "Turn on two-step login"
},
"changeAcctEmail": {
"message": "Change account email"
"message": "Ubah surel akun"
},
"extensionWidth": {
"message": "Lebar ekstensi"
@@ -5096,31 +5096,31 @@
"message": "Ekstra lebar"
},
"sshKeyWrongPassword": {
"message": "The password you entered is incorrect."
"message": "Kata sandi yang Anda masukkan tidak benar."
},
"importSshKey": {
"message": "Import"
"message": "Impor"
},
"confirmSshKeyPassword": {
"message": "Confirm password"
"message": "Konfirmasi kata sandi"
},
"enterSshKeyPasswordDesc": {
"message": "Enter the password for the SSH key."
"message": "Masukkan kata sandi untuk kunci SSH."
},
"enterSshKeyPassword": {
"message": "Enter password"
"message": "Masukkan kata sandi"
},
"invalidSshKey": {
"message": "The SSH key is invalid"
"message": "Kunci SSH tidak valid"
},
"sshKeyTypeUnsupported": {
"message": "The SSH key type is not supported"
"message": "Tipe kunci SSH tidak didukung"
},
"importSshKeyFromClipboard": {
"message": "Import key from clipboard"
"message": "Impor kunci dari papan klip"
},
"sshKeyImported": {
"message": "SSH key imported successfully"
"message": "Kunci SSH sukses diimpor"
},
"cannotRemoveViewOnlyCollections": {
"message": "Anda tidak dapat menghapus koleksi dengan izin hanya lihat: $COLLECTIONS$",
@@ -5132,12 +5132,12 @@
}
},
"updateDesktopAppOrDisableFingerprintDialogTitle": {
"message": "Please update your desktop application"
"message": "Harap perbarui aplikasi desktop Anda"
},
"updateDesktopAppOrDisableFingerprintDialogMessage": {
"message": "To use biometric unlock, please update your desktop application, or disable fingerprint unlock in the desktop settings."
"message": "Untuk memakai pembuka kunci biometrik, harap perbarui aplikasi desktop Anda, atau matikan buka kunci sidik jari dalam pengaturan desktop."
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
"message": "Ubah kata sandi yang berrisiko"
}
}

View File

@@ -81,7 +81,7 @@
"message": "Hoofdwachtwoordhint (optioneel)"
},
"passwordStrengthScore": {
"message": "Score wachtwoordsterkte $SCORE$",
"message": "Wachtwoordsterkte score $SCORE$",
"placeholders": {
"score": {
"content": "$1",
@@ -878,10 +878,10 @@
"message": "Druk op je YubiKey om te verifiëren"
},
"duoTwoFactorRequiredPageSubtitle": {
"message": "Jouw account vereist Duo-tweestapsaanmelding. Volg de onderstaande stappen om het inloggen te voltooien."
"message": "Jouw account vereist Duo tweestapslogin. Volg de onderstaande stappen om het inloggen te voltooien."
},
"followTheStepsBelowToFinishLoggingIn": {
"message": "Volg de onderstaande stappen om in te loggen."
"message": "Volg de onderstaande stappen om het inloggen af te ronden."
},
"restartRegistration": {
"message": "Registratie herstarten"
@@ -1040,7 +1040,7 @@
"message": "Klik op items om automatisch in te vullen op de kluisweergave"
},
"clickToAutofill": {
"message": "Klik op gesuggereerde items om deze automatisch invullen"
"message": "Klik items in de automatisch invullen suggestie om in te vullen"
},
"clearClipboard": {
"message": "Klembord wissen",
@@ -1075,7 +1075,7 @@
"description": "Shown to user after login is updated."
},
"saveAsNewLoginAction": {
"message": "Als nieuwe login opslaan",
"message": "Opslaan als nieuwe login",
"description": "Button text for saving login details as a new entry."
},
"updateLoginAction": {
@@ -1422,7 +1422,7 @@
"message": "Mijn gegevens onthouden"
},
"dontAskAgainOnThisDeviceFor30Days": {
"message": "30 dagen niet meer vragen op dit apparaat"
"message": "30 dagen niet opnieuw vragen op dit apparaat"
},
"sendVerificationCodeEmailAgain": {
"message": "E-mail met verificatiecode opnieuw versturen"
@@ -1459,7 +1459,7 @@
"message": "Beveiligingssleutel lezen"
},
"awaitingSecurityKeyInteraction": {
"message": "Wacht op interactie met beveiligingssleutel..."
"message": "Wacht op interactie met beveiligingssleutel"
},
"loginUnavailable": {
"message": "Login niet beschikbaar"
@@ -1474,7 +1474,7 @@
"message": "Opties voor tweestapsaanmelding"
},
"selectTwoStepLoginMethod": {
"message": "Kies methode voor tweestapsaanmelding"
"message": "Kies methode voor tweestapslogin"
},
"recoveryCodeDesc": {
"message": "Ben je de toegang tot al je tweestapsaanbieders verloren? Gebruik dan je herstelcode om alle tweestapsaanbieders op je account uit te schakelen."
@@ -2164,7 +2164,7 @@
"description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'"
},
"vaultCustomization": {
"message": "Kluis-aanpassingen"
"message": "Kluis aanpassingen"
},
"vaultTimeoutAction": {
"message": "Actie bij time-out"
@@ -2176,10 +2176,10 @@
"message": "Nieuwe aanpassingsopties"
},
"newCustomizationOptionsCalloutContent": {
"message": "Personaliseer je kluiservaring met snelle kopieeracties, compacte modus en meer!"
"message": "Pas je kluis ervaring aan met snelle kopieeracties, compacte modus en meer!"
},
"newCustomizationOptionsCalloutLink": {
"message": "Alle personalisatie-instellingen bekijken"
"message": "Alle weergave-instellingen bekijken"
},
"lock": {
"message": "Vergrendelen",
@@ -2498,7 +2498,7 @@
}
},
"atRiskPasswordsDescMultiOrgPlural": {
"message": "Je organisatie(s) vragen je de $COUNT$ wachtwoorden te wijzigen omdat ze een risico vormen.",
"message": "Je organisaties vragen je de $COUNT$ wachtwoorden te wijzigen omdat ze een risico vormen.",
"placeholders": {
"count": {
"content": "$1",
@@ -2531,14 +2531,14 @@
"message": "Risicovolle wachtwoorden bekijken"
},
"reviewAtRiskLoginsSlideDesc": {
"message": "De wachtwoorden van je organisatie zijn in gevaar omdat ze zwak, hergebruikt en/of blootgelegd zijn.",
"message": "De wachtwoorden van je organisatie zijn in gevaar omdat ze zwak, hergebruikt en/of gelekt zijn.",
"description": "Description of the review at-risk login slide on the at-risk password page carousel"
},
"reviewAtRiskLoginSlideImgAlt": {
"message": "Voorbeeld van een lijst van risicovolle logins"
},
"generatePasswordSlideDesc": {
"message": "Genereer snel een sterk, uniek wachtwoord met het automatisch invulmenu van Bitwaren op de risicovolle website.",
"message": "Genereer snel een sterk, uniek wachtwoord met het automatisch invulmenu van Bitwarden op de risicovolle website.",
"description": "Description of the generate password slide on the at-risk password page carousel"
},
"generatePasswordSlideImgAlt": {
@@ -2552,7 +2552,7 @@
"description": "Description of the update in Bitwarden slide on the at-risk password page carousel"
},
"updateInBitwardenSlideImgAlt": {
"message": "Voorbeeld van een Bitwarden-melding die de gebruiker aanspoort tot het bijwerken van de login"
"message": "Voorbeeld van een Bitwarden melding die de gebruiker aanspoort tot het bijwerken van de login"
},
"turnOnAutofill": {
"message": "Automatisch invullen inschakelen"
@@ -4080,7 +4080,7 @@
"message": "Actief account"
},
"bitwardenAccount": {
"message": "Bitwarden-account"
"message": "Bitwarden account"
},
"availableAccounts": {
"message": "Beschikbare accounts"
@@ -5096,7 +5096,7 @@
"message": "Extra breed"
},
"sshKeyWrongPassword": {
"message": "Het door jou ingevoerde wachtwoord is onjuist."
"message": "Het wachtwoord dat je hebt ingevoerd is onjuist."
},
"importSshKey": {
"message": "Importeren"
@@ -5117,7 +5117,7 @@
"message": "Het type SSH-sleutel is niet ondersteund"
},
"importSshKeyFromClipboard": {
"message": "Sleutel van klembord importeren"
"message": "Sleutel importeren van klembord"
},
"sshKeyImported": {
"message": "SSH-sleutel succesvol geïmporteerd"

View File

@@ -2208,7 +2208,7 @@
"message": "项目已恢复"
},
"alreadyHaveAccount": {
"message": "已经有账户了吗?"
"message": "已经有账户了吗?"
},
"vaultTimeoutLogOutConfirmation": {
"message": "超时后注销账户将解除对密码库的所有访问权限,并需要进行在线身份验证。确定使用此设置吗?"
@@ -3628,7 +3628,7 @@
"message": "正在获取选项..."
},
"multiSelectNotFound": {
"message": "未找到任何目"
"message": "未找到任何目"
},
"multiSelectClearAll": {
"message": "清除全部"
@@ -4209,7 +4209,7 @@
"message": "建议的项目"
},
"autofillSuggestionsTip": {
"message": "将此站点保存登录项目以用于自动填充"
"message": "为这个站点保存一个登录项目以自动填充"
},
"yourVaultIsEmpty": {
"message": "您的密码库是空的"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -907,7 +907,7 @@
"description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated."
},
"verifyYourIdentity": {
"message": "Verify your Identity"
"message": "Potwierdź swoją tożsamość"
},
"weDontRecognizeThisDevice": {
"message": "Nie rozpoznajemy tego urządzenia. Wpisz kod wysłany na Twój e-mail, aby zweryfikować tożsamość."
@@ -998,7 +998,7 @@
"message": "Nie"
},
"location": {
"message": "Location"
"message": "Lokalizacja"
},
"overwritePassword": {
"message": "Zastąp hasło"
@@ -3488,22 +3488,22 @@
"message": "Warning: Agent Forwarding"
},
"agentForwardingWarningText": {
"message": "This request comes from a remote device that you are logged into"
"message": "To żądanie pochodzi ze zdalnego urządzenia, do którego jesteś zalogowany"
},
"sshkeyApprovalMessageInfix": {
"message": "wnioskuje o dostęp do"
},
"sshkeyApprovalMessageSuffix": {
"message": "in order to"
"message": "w celu"
},
"sshActionLogin": {
"message": "authenticate to a server"
"message": "autoryzacji na serwerze"
},
"sshActionSign": {
"message": "sign a message"
"message": "podpisania wiadomości"
},
"sshActionGitSign": {
"message": "sign a git commit"
"message": "podpisania commita w giciem"
},
"unknownApplication": {
"message": "Aplikacja"
@@ -3518,7 +3518,7 @@
"message": "Importuj klucz ze schowka"
},
"sshKeyImported": {
"message": "SSH key imported successfully"
"message": "Klucz SSH zaimportowano pomyślnie"
},
"fileSavedToDevice": {
"message": "Plik zapisany na urządzeniu. Zarządzaj plikiem na swoim urządzeniu."
@@ -3578,6 +3578,6 @@
"message": "Rozszerzenie przeglądarki, którego używasz, jest nieaktualne. Zaktualizuj je lub wyłącz weryfikację odcisku palca integracji przeglądarki w ustawieniach aplikacji desktopowej."
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
"message": "Zmień zagrożone hasło"
}
}

View File

@@ -2470,7 +2470,7 @@
"message": "切换账户"
},
"alreadyHaveAccount": {
"message": "已经有账户了吗?"
"message": "已经有账户了吗?"
},
"options": {
"message": "选项"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2025.3.0",
"version": "2025.3.1",
"scripts": {
"build:oss": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",
"build:bit": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@@ -9847,13 +9847,13 @@
"message": "Lees meer over Bitwarden's API"
},
"fileSend": {
"message": "Bestand verzenden"
"message": "Bestand Send"
},
"fileSends": {
"message": "Bestand-Sends"
},
"textSend": {
"message": "Tekst-Sends"
"message": "Tekst Send"
},
"textSends": {
"message": "Tekst-Sends"
@@ -10382,7 +10382,7 @@
"message": "Bitwarden-browserextensie openen"
},
"somethingWentWrong": {
"message": "Er is iets fout gegaan..."
"message": "Er is iets fout gegaan"
},
"openingExtensionError": {
"message": "We konden de Bitwarden-browserextensie niet openen. Klik op de knop om deze nu te openen."
@@ -10407,7 +10407,7 @@
"description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'We had trouble opening the Bitwarden browser extension. Open the Bitwarden icon [Bitwarden Icon] from the toolbar.'"
},
"openExtensionManuallyPart2": {
"message": "vanaf de werkbank.",
"message": "vanaf de werkbalk.",
"description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'We had trouble opening the Bitwarden browser extension. Open the Bitwarden icon [Bitwarden Icon] from the toolbar.'"
},
"resellerRenewalWarningMsg": {
@@ -10542,7 +10542,7 @@
"message": "Upgrade voor echte event log gegevens"
},
"upgradeEventLogMessage": {
"message": "Deze events zijn voorbeelden en weerspiegelen geen echte evenementen binnen je Bitwarden-organisatie."
"message": "Deze evenementen zijn alleen voorbeelden en weerspiegelen geen echte evenementen binnen je Bitwarden organisatie."
},
"cannotCreateCollection": {
"message": "Gratis organisaties kunnen maximaal twee collecties hebben. Upgrade naar een betaald abonnement voor het toevoegen van meer collecties."

View File

@@ -2500,7 +2500,7 @@
}
},
"noInactive2fa": {
"message": "没有在您的密码库发现未配置两步登录的网站。"
"message": "在您的密码库中没有发现未配置两步登录的网站。"
},
"instructions": {
"message": "说明"
@@ -2596,7 +2596,7 @@
}
},
"noReusedPasswords": {
"message": "您密码库中没有密码重复使用的项目。"
"message": "您密码库中没有密码重复使用的登录项目。"
},
"timesReused": {
"message": "重复使用次数"
@@ -7921,7 +7921,7 @@
}
},
"domainNotVerifiedEvent": {
"message": "$DOMAIN$ 验证",
"message": "$DOMAIN$ 无法验证",
"placeholders": {
"DOMAIN": {
"content": "$1",
@@ -8779,7 +8779,7 @@
"description": "Label for field requesting a self-hosted integration service URL"
},
"alreadyHaveAccount": {
"message": "已经有账户了吗?"
"message": "已经有账户了吗?"
},
"toggleSideNavigation": {
"message": "切换侧边导航"
@@ -10294,7 +10294,7 @@
"message": "已声明"
},
"domainStatusUnderVerification": {
"message": "正在验证"
"message": "验证"
},
"claimedDomainsDesc": {
"message": "声明一个域名,以拥有电子邮箱地址与该域名匹配的所有成员账户。成员登录时将可以跳过 SSO 标识符。管理员也可以删除成员账户。"

View File

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

View File

@@ -1,17 +1,23 @@
<bit-callout type="danger" title="{{ 'vaultExportDisabled' | i18n }}" *ngIf="disabledByPolicy">
<bit-callout
type="danger"
title="{{ 'vaultExportDisabled' | i18n }}"
*ngIf="disablePersonalVaultExportPolicy$ | async"
>
{{ "personalVaultExportPolicyInEffect" | i18n }}
</bit-callout>
<tools-export-scope-callout
[organizationId]="organizationId"
*ngIf="!disabledByPolicy"
></tools-export-scope-callout>
<tools-export-scope-callout [organizationId]="organizationId"></tools-export-scope-callout>
<form [formGroup]="exportForm" [bitSubmit]="submit" id="export_form_exportForm">
<ng-container *ngIf="organizations$ | async as organizations">
<bit-form-field *ngIf="organizations.length > 0">
<bit-label>{{ "exportFrom" | i18n }}</bit-label>
<bit-select formControlName="vaultSelector">
<bit-option [label]="'myVault' | i18n" value="myVault" icon="bwi-user" />
<bit-option
[label]="'myVault' | i18n"
value="myVault"
icon="bwi-user"
*ngIf="!(disablePersonalOwnershipPolicy$ | async)"
/>
<bit-option
*ngFor="let o of organizations$ | async"
[value]="o.id"

View File

@@ -22,6 +22,7 @@ import {
Subject,
switchMap,
takeUntil,
tap,
} from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
@@ -154,6 +155,9 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
return this._disabledByPolicy;
}
disablePersonalVaultExportPolicy$: Observable<boolean>;
disablePersonalOwnershipPolicy$: Observable<boolean>;
exportForm = this.formBuilder.group({
vaultSelector: [
"myVault",
@@ -201,15 +205,13 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
this.formDisabled.emit(c === "DISABLED");
});
this.policyService
.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport)
.pipe(takeUntil(this.destroy$))
.subscribe((policyAppliesToActiveUser) => {
this._disabledByPolicy = policyAppliesToActiveUser;
if (this.disabledByPolicy) {
this.exportForm.disable();
}
});
// policies
this.disablePersonalVaultExportPolicy$ = this.policyService.policyAppliesToActiveUser$(
PolicyType.DisablePersonalVaultExport,
);
this.disablePersonalOwnershipPolicy$ = this.policyService.policyAppliesToActiveUser$(
PolicyType.PersonalOwnership,
);
merge(
this.exportForm.get("format").valueChanges,
@@ -269,13 +271,45 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
}),
);
combineLatest([
this.disablePersonalVaultExportPolicy$,
this.disablePersonalOwnershipPolicy$,
this.organizations$,
])
.pipe(
tap(([disablePersonalVaultExport, disablePersonalOwnership, organizations]) => {
this._disabledByPolicy = disablePersonalVaultExport;
// When personalOwnership is disabled and we have orgs, set the first org as the selected vault
if (disablePersonalOwnership && organizations.length > 0) {
this.exportForm.enable();
this.exportForm.controls.vaultSelector.setValue(organizations[0].id);
}
// When personalOwnership is disabled and we have no orgs, disable the form
if (disablePersonalOwnership && organizations.length === 0) {
this.exportForm.disable();
}
// When personalVaultExport is disabled, disable the form
if (disablePersonalVaultExport) {
this.exportForm.disable();
}
// When neither policy is enabled, enable the form and set the default vault to "myVault"
if (!disablePersonalVaultExport && !disablePersonalOwnership) {
this.exportForm.controls.vaultSelector.setValue("myVault");
}
}),
takeUntil(this.destroy$),
)
.subscribe();
this.exportForm.controls.vaultSelector.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((value) => {
this.organizationId = value != "myVault" ? value : undefined;
});
this.exportForm.controls.vaultSelector.setValue("myVault");
}
ngAfterViewInit(): void {
@@ -286,6 +320,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
get encryptedFormat() {

2
package-lock.json generated
View File

@@ -243,7 +243,7 @@
},
"apps/web": {
"name": "@bitwarden/web-vault",
"version": "2025.3.0"
"version": "2025.3.1"
},
"libs/admin-console": {
"name": "@bitwarden/admin-console",