1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 11:43:51 +00:00

Support PRF login on desktop for security keys

This commit is contained in:
Bernd Schoolmann
2025-11-17 13:45:17 +01:00
parent 9733ef0a3e
commit 829dc670e9
25 changed files with 929 additions and 26 deletions

View File

@@ -51,8 +51,10 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { NavigatorCredentialsService } from "@bitwarden/common/auth/abstractions/webauthn/navigator-credentials.service";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { DefaultNavigatorCredentialsService } from "@bitwarden/common/auth/services/webauthn-login/default-navigator-credentials.service";
import {
AutofillSettingsService,
AutofillSettingsServiceAbstraction,
@@ -717,6 +719,11 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionNewDeviceVerificationComponentService,
deps: [],
}),
safeProvider({
provide: NavigatorCredentialsService,
useClass: DefaultNavigatorCredentialsService,
deps: [WINDOW, PlatformUtilsService],
}),
safeProvider({
provide: SessionTimeoutSettingsComponentService,
useClass: BrowserSessionTimeoutSettingsComponentService,

View File

@@ -193,6 +193,45 @@ dependencies = [
"nom",
]
[[package]]
name = "asn1-rs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -608,7 +647,7 @@ dependencies = [
"async-trait",
"base64",
"cbc",
"dirs",
"dirs 6.0.0",
"hex",
"oo7",
"pbkdf2",
@@ -692,7 +731,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
dependencies = [
"serde",
"termcolor",
"unicode-width",
"unicode-width 0.2.0",
]
[[package]]
@@ -779,6 +818,29 @@ dependencies = [
"typenum",
]
[[package]]
name = "ctap-hid-fido2"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6473a333d82796d5b23529fde19386de33820ad19d368402f8e9de780e1723"
dependencies = [
"aes",
"anyhow",
"base64",
"byteorder",
"cbc",
"hex",
"hidapi",
"num",
"pad",
"ring",
"serde",
"serde_cbor",
"strum",
"strum_macros",
"x509-parser",
]
[[package]]
name = "ctor"
version = "0.2.9"
@@ -900,6 +962,12 @@ dependencies = [
"syn",
]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "der"
version = "0.7.10"
@@ -911,6 +979,29 @@ dependencies = [
"zeroize",
]
[[package]]
name = "der-parser"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
dependencies = [
"powerfmt",
]
[[package]]
name = "desktop_core"
version = "0.0.0"
@@ -927,7 +1018,7 @@ dependencies = [
"chacha20poly1305",
"core-foundation",
"desktop_objc",
"dirs",
"dirs 6.0.0",
"ed25519",
"futures",
"homedir",
@@ -975,6 +1066,7 @@ dependencies = [
"base64",
"chromium_importer",
"desktop_core",
"fido2_client",
"hex",
"napi",
"napi-build",
@@ -1029,13 +1121,34 @@ dependencies = [
"subtle",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
]
[[package]]
@@ -1046,7 +1159,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"redox_users 0.5.0",
"windows-sys 0.61.2",
]
@@ -1143,6 +1256,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "elliptic-curve"
version = "0.13.8"
@@ -1288,6 +1407,18 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "fido2_client"
version = "0.0.0"
dependencies = [
"base64",
"ctap-hid-fido2",
"pinentry",
"secrecy",
"serde",
"sha2",
]
[[package]]
name = "fixedbitset"
version = "0.4.2"
@@ -1520,6 +1651,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "half"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403"
[[package]]
name = "hashbrown"
version = "0.15.3"
@@ -1556,6 +1693,18 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hidapi"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "798154e4b6570af74899d71155fb0072d5b17e6aa12f39c8ef22c60fb8ec99e7"
dependencies = [
"cc",
"libc",
"pkg-config",
"winapi",
]
[[package]]
name = "hkdf"
version = "0.12.4"
@@ -2157,6 +2306,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -2290,6 +2445,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "oid-registry"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -2397,6 +2561,15 @@ dependencies = [
"sha2",
]
[[package]]
name = "pad"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ad9b889f1b12e0b9ee24db044b5129150d5eada288edc800f789928dc8c0e3"
dependencies = [
"unicode-width 0.1.14",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -2499,6 +2672,20 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pinentry"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72268b7db3a2075ea65d4b93b755d086e99196e327837e690db6559b393a8d69"
dependencies = [
"log",
"nom",
"percent-encoding",
"secrecy",
"which",
"zeroize",
]
[[package]]
name = "piper"
version = "0.2.4"
@@ -2607,6 +2794,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -2777,6 +2970,17 @@ dependencies = [
"bitflags",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.0"
@@ -2827,6 +3031,20 @@ dependencies = [
"subtle",
]
[[package]]
name = "ring"
version = "0.17.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e75ec5e92c4d8aede845126adc388046234541629e76029599ed35a003c7ed24"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.16",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rsa"
version = "0.9.6"
@@ -2887,6 +3105,15 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustix"
version = "0.38.44"
@@ -3031,6 +3258,15 @@ dependencies = [
"windows 0.61.1",
]
[[package]]
name = "secrecy"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e"
dependencies = [
"zeroize",
]
[[package]]
name = "security-framework"
version = "3.5.0"
@@ -3072,6 +3308,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde_cbor"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5"
dependencies = [
"half",
"serde",
]
[[package]]
name = "serde_derive"
version = "1.0.209"
@@ -3315,6 +3561,25 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -3443,6 +3708,37 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
[[package]]
name = "time-macros"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.1"
@@ -3693,6 +3989,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
@@ -3839,6 +4141,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.4"
@@ -4007,6 +4315,18 @@ dependencies = [
"nom",
]
[[package]]
name = "which"
version = "4.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ad25fe5717e59ada8ea33511bbbf7420b11031730a24c65e82428766c307006"
dependencies = [
"dirs 5.0.1",
"either",
"once_cell",
"rustix 0.38.44",
]
[[package]]
name = "widestring"
version = "1.2.0"
@@ -4242,6 +4562,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -4600,6 +4929,23 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d"
[[package]]
name = "x509-parser"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "yoke"
version = "0.8.0"

View File

@@ -5,6 +5,7 @@ members = [
"bitwarden_chromium_import_helper",
"chromium_importer",
"core",
"fido2_client",
"macos_provider",
"napi",
"process_isolation",

View File

@@ -0,0 +1,14 @@
[package]
name = "fido2_client"
edition = { workspace = true }
license = { workspace = true }
version = { workspace = true }
publish = { workspace = true }
[dependencies]
base64 = { workspace = true }
ctap-hid-fido2 = "3.5.1"
pinentry = "0.5.0"
serde = { workspace = true, features = ["derive"] }
secrecy = "0.8.0"
sha2 = { workspace = true }

View File

@@ -0,0 +1,145 @@
use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine};
use ctap_hid_fido2::{
fidokey::{AssertionExtension, GetAssertionArgsBuilder},
Cfg, FidoKeyHidFactory,
};
use pinentry::PassphraseInput;
use secrecy::ExposeSecret;
use crate::{
prf_to_hmac, AuthenticatorAssertionResponse, Fido2ClientError, PublicKeyCredential,
PublicKeyCredentialRequestOptions,
};
fn get_pin() -> Option<String> {
if let Some(mut input) = PassphraseInput::with_default_binary() {
input
.with_description("Enter your FIDO2 Authenticator PIN:")
.with_prompt("PIN:")
.interact()
.ok()
.map(|p| p.expose_secret().to_owned())
} else {
None
}
}
pub fn available() -> bool {
true
}
fn make_assertion(
options: PublicKeyCredentialRequestOptions,
client_data_json: String,
credential: Option<&[u8]>,
) -> Result<GetAssertionArgsBuilder, Fido2ClientError> {
let mut get_assertion_args =
GetAssertionArgsBuilder::new(options.rp_id.as_str(), client_data_json.as_bytes())
.extensions(&[AssertionExtension::HmacSecret(Some(prf_to_hmac(
&options.prf_eval_first,
)))]);
if let Some(cred) = credential {
get_assertion_args = get_assertion_args.credential_id(cred);
}
Ok(get_assertion_args)
}
pub fn get(
options: PublicKeyCredentialRequestOptions,
) -> Result<PublicKeyCredential, Fido2ClientError> {
let device = FidoKeyHidFactory::create(&Cfg::init()).map_err(|_| Fido2ClientError::NoDevice)?;
let client_data_json = format!(
r#"{{"type":"webauthn.get","challenge":"{}","origin":"https://{}","crossOrigin": true}}"#,
BASE64_URL_SAFE_NO_PAD.encode(&options.challenge),
options.rp_id
);
let mut get_assertion_args = make_assertion(
options.clone(),
client_data_json.clone(),
options.allow_credentials.get(0).map(|v| v.as_slice()),
)?;
let pin: String;
if options.user_verification == crate::UserVerification::Required
|| options.user_verification == crate::UserVerification::Preferred
{
pin = get_pin().ok_or(Fido2ClientError::WrongPin)?;
get_assertion_args = get_assertion_args.pin(pin.as_str());
}
let mut assertions = device
.get_assertion_with_args(&get_assertion_args.build())
.map_err(|_e| Fido2ClientError::AssertionError)?;
let assertion = if assertions.len() > 1 {
let first_assertion = &assertions[0];
let mut get_assertion_args = make_assertion(
options.clone(),
client_data_json.clone(),
Some(&first_assertion.credential_id),
)?;
let pin: String;
if options.user_verification == crate::UserVerification::Required
|| options.user_verification == crate::UserVerification::Preferred
{
pin = get_pin().ok_or(Fido2ClientError::WrongPin)?;
get_assertion_args = get_assertion_args.pin(pin.as_str());
}
assertions = device
.get_assertion_with_args(&get_assertion_args.build())
.map_err(|_e| Fido2ClientError::AssertionError)?;
assertions.get(0).ok_or(Fido2ClientError::AssertionError)?
} else {
assertions.get(0).ok_or(Fido2ClientError::AssertionError)?
};
let prf_extension = assertion
.extensions
.iter()
.find_map(|ext| {
if let AssertionExtension::HmacSecret(results) = ext {
Some(*results)
} else {
None
}
})
.flatten();
Ok(PublicKeyCredential {
authenticator_attachment: "cross-platform".to_string(),
id: BASE64_URL_SAFE_NO_PAD.encode(&assertion.credential_id),
raw_id: assertion.credential_id.clone(),
response: AuthenticatorAssertionResponse {
authenticator_data: assertion.auth_data.clone(),
client_data_json: client_data_json.as_bytes().to_vec(),
signature: assertion.signature.clone(),
user_handle: assertion.user.id.clone(),
},
r#type: "public-key".to_string(),
prf: prf_extension,
})
}
#[cfg(test)]
mod tests {
use crate::{ctap_hid_fido2::get, PublicKeyCredentialRequestOptions};
#[test]
#[ignore]
fn assertion() {
get(PublicKeyCredentialRequestOptions {
challenge: vec![],
timeout: 0,
rp_id: "vault.usdev.bitwarden.pw".to_string(),
user_verification: crate::UserVerification::Required,
allow_credentials: vec![],
prf_eval_first: [0u8; 32],
prf_eval_second: None,
})
.unwrap();
}
}

View File

@@ -0,0 +1,78 @@
#[cfg(all(target_os = "linux", target_env = "gnu"))]
mod ctap_hid_fido2;
#[cfg(all(target_os = "linux", target_env = "gnu"))]
use ctap_hid_fido2::*;
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
mod unimplemented;
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
use unimplemented::*;
#[cfg(all(target_os = "linux", target_env = "gnu"))]
/// Depending on the platform API, the platform MAY do this for you, or may require you to do it manually.
fn prf_to_hmac(prf_salt: &[u8]) -> [u8; 32] {
use sha2::Digest;
sha2::Sha256::digest(&[b"WebAuthn PRF".as_slice(), &[0], prf_salt].concat()).into()
}
#[derive(Debug, PartialEq, Clone)]
pub enum UserVerification {
Discouraged,
Preferred,
Required,
}
#[derive(Debug, Clone)]
pub struct PrfConfig {
pub first: Vec<u8>,
pub second: Option<Vec<u8>>,
}
#[derive(Debug, Clone)]
pub struct PublicKeyCredentialRequestOptions {
pub challenge: Vec<u8>,
pub timeout: u64,
pub rp_id: String,
pub user_verification: UserVerification,
pub allow_credentials: Vec<Vec<u8>>,
pub prf: Option<PrfConfig>,
}
#[derive(Debug)]
pub struct AuthenticatorAssertionResponse {
pub authenticator_data: Vec<u8>,
pub client_data_json: Vec<u8>,
pub signature: Vec<u8>,
pub user_handle: Vec<u8>,
}
#[derive(Debug)]
pub struct PublicKeyCredential {
pub authenticator_attachment: String,
pub id: String,
pub raw_id: Vec<u8>,
pub response: AuthenticatorAssertionResponse,
pub r#type: String,
pub prf: Option<[u8; 32]>,
}
#[derive(Debug)]
pub enum Fido2ClientError {
WrongPin,
NoCredentials,
NoDevice,
InvalidInput,
AssertionError,
}
pub mod fido2_client {
pub fn get(
assertion_options: super::PublicKeyCredentialRequestOptions,
) -> Result<super::PublicKeyCredential, super::Fido2ClientError> {
super::get(assertion_options)
}
pub fn available() -> bool {
super::available()
}
}

View File

@@ -0,0 +1,11 @@
use crate::{Fido2ClientError, PublicKeyCredential, PublicKeyCredentialRequestOptions};
pub fn get(
_options: PublicKeyCredentialRequestOptions,
) -> Result<PublicKeyCredential, Fido2ClientError> {
todo!("Fido2Client is unimplemented on this platform");
}
pub fn available() -> bool {
false
}

View File

@@ -19,6 +19,7 @@ autotype = { path = "../autotype" }
base64 = { workspace = true }
chromium_importer = { path = "../chromium_importer" }
desktop_core = { path = "../core" }
fido2_client = { path = "../fido2_client" }
hex = { workspace = true }
napi = { workspace = true, features = ["async"] }
napi-derive = { workspace = true }

View File

@@ -254,3 +254,38 @@ export declare namespace autotype {
export function getForegroundWindowTitle(): string
export function typeInput(input: Array<number>, keyboardShortcut: Array<string>): void
}
export declare namespace navigator_credentials {
export const enum UserVerification {
Preferred = 'Preferred',
Required = 'Required',
Discouraged = 'Discouraged'
}
export interface PrfConfig {
first: Uint8Array
second?: Uint8Array
}
export interface PublicKeyCredentialRequestOptions {
challenge: Uint8Array
timeout: number
rpId: string
userVerification: UserVerification
allowCredentials: Array<Uint8Array>
prf?: PrfConfig
}
export interface AuthenticatorAssertionResponse {
authenticatorData: Uint8Array
clientDataJson: Uint8Array
signature: Uint8Array
userHandle: Uint8Array
}
export interface PublicKeyCredential {
authenticatorAttachment: string
id: string
rawId: Uint8Array
response: AuthenticatorAssertionResponse
type: string
prf?: Uint8Array
}
export function get(assertionOptions: PublicKeyCredentialRequestOptions): PublicKeyCredential
export function available(): boolean
}

View File

@@ -1199,3 +1199,125 @@ pub mod autotype {
})
}
}
#[napi]
pub mod navigator_credentials {
use napi::bindgen_prelude::Uint8Array;
#[napi(string_enum)]
pub enum UserVerification {
Preferred,
Required,
Discouraged,
}
impl Into<fido2_client::UserVerification> for UserVerification {
fn into(self) -> fido2_client::UserVerification {
match self {
UserVerification::Preferred => fido2_client::UserVerification::Preferred,
UserVerification::Required => fido2_client::UserVerification::Required,
UserVerification::Discouraged => fido2_client::UserVerification::Discouraged,
}
}
}
#[napi(object)]
pub struct PrfConfig {
pub first: Uint8Array,
pub second: Option<Uint8Array>,
}
impl Into<fido2_client::PrfConfig> for PrfConfig {
fn into(self) -> fido2_client::PrfConfig {
fido2_client::PrfConfig {
first: self.first.to_vec(),
second: self.second.map(|s| s.to_vec()),
}
}
}
#[napi(object)]
pub struct PublicKeyCredentialRequestOptions {
pub challenge: Uint8Array,
pub timeout: i64,
pub rp_id: String,
pub user_verification: UserVerification,
pub allow_credentials: Vec<Uint8Array>,
pub prf: Option<PrfConfig>,
}
impl TryInto<fido2_client::PublicKeyCredentialRequestOptions>
for PublicKeyCredentialRequestOptions
{
type Error = napi::Error;
fn try_into(self) -> Result<fido2_client::PublicKeyCredentialRequestOptions, Self::Error> {
Ok(fido2_client::PublicKeyCredentialRequestOptions {
challenge: self.challenge.to_vec(),
timeout: self.timeout as u64,
rp_id: self.rp_id,
user_verification: self.user_verification.into(),
allow_credentials: self.allow_credentials.iter().map(|c| c.to_vec()).collect(),
prf: self.prf.map(|p| p.into()),
})
}
}
#[napi(object)]
pub struct AuthenticatorAssertionResponse {
pub authenticator_data: Uint8Array,
pub client_data_json: Uint8Array,
pub signature: Uint8Array,
pub user_handle: Uint8Array,
}
impl From<fido2_client::AuthenticatorAssertionResponse> for AuthenticatorAssertionResponse {
fn from(response: fido2_client::AuthenticatorAssertionResponse) -> Self {
AuthenticatorAssertionResponse {
authenticator_data: Uint8Array::from(response.authenticator_data),
client_data_json: Uint8Array::from(response.client_data_json),
signature: Uint8Array::from(response.signature),
user_handle: Uint8Array::from(response.user_handle),
}
}
}
#[napi(object)]
pub struct PublicKeyCredential {
pub authenticator_attachment: String,
pub id: String,
pub raw_id: Uint8Array,
pub response: AuthenticatorAssertionResponse,
pub r#type: String,
pub prf: Option<Uint8Array>,
}
impl Into<PublicKeyCredential> for fido2_client::PublicKeyCredential {
fn into(self) -> PublicKeyCredential {
PublicKeyCredential {
authenticator_attachment: self.authenticator_attachment,
id: self.id,
raw_id: Uint8Array::from(self.raw_id),
response: self.response.into(),
r#type: self.r#type,
prf: self.prf.map(|p| Uint8Array::from(p)),
}
}
}
#[napi]
pub fn get(
assertion_options: PublicKeyCredentialRequestOptions,
) -> napi::Result<PublicKeyCredential> {
let options: fido2_client::PublicKeyCredentialRequestOptions =
assertion_options.try_into()?;
fido2_client::fido2_client::get(options)
.map_err(|e| napi::Error::from_reason(format!("FIDO2 Authentication failed: {:?}", e)))
.map(|credential| credential.into())
}
#[napi]
pub fn available() -> bool {
fido2_client::fido2_client::available()
}
}

View File

@@ -12,6 +12,7 @@ import {
tdeDecryptionRequiredGuard,
unauthGuardFn,
} from "@bitwarden/angular/auth/guards";
import { LoginViaWebAuthnComponent } from "@bitwarden/angular/auth/login-via-webauthn/login-via-webauthn.component";
import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password";
import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component";
import {
@@ -23,6 +24,7 @@ import {
VaultIcon,
LockIcon,
DomainIcon,
TwoFactorAuthSecurityKeyIcon,
} from "@bitwarden/assets/svg";
import {
LoginComponent,
@@ -123,6 +125,27 @@ const routes: Routes = [
path: "",
component: AnonLayoutWrapperComponent,
children: [
{
path: AuthRoute.LoginWithPasskey,
canActivate: [unauthGuardFn()],
data: {
pageIcon: TwoFactorAuthSecurityKeyIcon,
pageTitle: {
key: "logInWithPasskey",
},
pageSubtitle: {
key: "readingPasskeyLoadingInfo",
},
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaWebAuthnComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: AuthRoute.SignUp,
canActivate: [unauthGuardFn()],

View File

@@ -51,6 +51,7 @@ import {
} from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { NavigatorCredentialsService } from "@bitwarden/common/auth/abstractions/webauthn/navigator-credentials.service";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ClientType } from "@bitwarden/common/enums";
@@ -119,6 +120,7 @@ import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarde
import { DesktopLoginApprovalDialogComponentService } from "../../auth/login/desktop-login-approval-dialog-component.service";
import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service";
import { DesktopTwoFactorAuthDuoComponentService } from "../../auth/services/desktop-two-factor-auth-duo-component.service";
import { RendererNavigatorCredentialsService } from "../../auth/services/renderer-navigator-credentials.service";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
import { DesktopAutotypeDefaultSettingPolicy } from "../../autofill/services/desktop-autotype-policy.service";
@@ -310,6 +312,11 @@ const safeProviders: SafeProvider[] = [
useClass: WebCryptoFunctionService,
deps: [WINDOW],
}),
safeProvider({
provide: NavigatorCredentialsService,
useClass: RendererNavigatorCredentialsService,
deps: [],
}),
safeProvider({
provide: KeyServiceAbstraction,
useClass: ElectronKeyService,

View File

@@ -1,5 +1,7 @@
import { ipcRenderer } from "electron";
import { navigator_credentials } from "@bitwarden/desktop-napi";
export default {
loginRequest: (alertTitle: string, alertBody: string, buttonText: string): Promise<void> =>
ipcRenderer.invoke("loginRequest", {
@@ -7,4 +9,10 @@ export default {
alertBody,
buttonText,
}),
navigatorCredentialsGet: (
options: navigator_credentials.PublicKeyCredentialRequestOptions,
): Promise<navigator_credentials.PublicKeyCredential | null> =>
ipcRenderer.invoke("navigatorCredentials.get", options),
navigatorCredentialsAvailable: (): Promise<boolean> =>
ipcRenderer.invoke("navigatorCredentials.available"),
};

View File

@@ -0,0 +1,17 @@
import { ipcMain } from "electron";
import { navigator_credentials } from "@bitwarden/desktop-napi";
export class MainNavigatorCredentialsService {
constructor() {
ipcMain.handle(
"navigatorCredentials.get",
async (_event: any, message: navigator_credentials.PublicKeyCredentialRequestOptions) => {
return navigator_credentials.get(message);
},
);
ipcMain.handle("navigatorCredentials.available", async (_event: any, _message: any) => {
return navigator_credentials.available();
});
}
}

View File

@@ -0,0 +1,52 @@
import { navigator_credentials } from "apps/desktop/desktop_native/napi";
import { NavigatorCredentialsService } from "@bitwarden/common/auth/abstractions/webauthn/navigator-credentials.service";
export class RendererNavigatorCredentialsService implements NavigatorCredentialsService {
constructor() {}
async get(options: CredentialRequestOptions): Promise<Credential | null> {
return await ipc.auth.navigatorCredentialsGet({
challenge: arrayBufferSourceToUint8Array(options.publicKey.challenge),
timeout: options.publicKey.timeout,
rpId: options.publicKey.rpId,
userVerification: convertUserVerification(options.publicKey.userVerification),
allowCredentials: options.publicKey.allowCredentials.map((cred) => {
return arrayBufferSourceToUint8Array(cred.id);
}),
prf: options.publicKey.extensions?.prf
? {
first: arrayBufferSourceToUint8Array(options.publicKey.extensions!.prf!.eval.first),
second: undefined,
}
: undefined,
});
}
async available(): Promise<boolean> {
return await ipc.auth.navigatorCredentialsAvailable();
}
}
function arrayBufferSourceToUint8Array(source: BufferSource): Uint8Array {
if (source instanceof ArrayBuffer) {
return new Uint8Array(source);
} else {
return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
}
}
function convertUserVerification(
userVerification: UserVerificationRequirement | undefined,
): navigator_credentials.UserVerification | undefined {
switch (userVerification) {
case "required":
return navigator_credentials.UserVerification.Required;
case "preferred":
return navigator_credentials.UserVerification.Preferred;
case "discouraged":
return navigator_credentials.UserVerification.Discouraged;
default:
return undefined;
}
}

View File

@@ -34,6 +34,7 @@ import {
import { SerializedMemoryStorageService, StorageServiceProvider } from "@bitwarden/storage-core";
import { ChromiumImporterService } from "./app/tools/import/chromium-importer.service";
import { MainNavigatorCredentialsService } from "./auth/services/main-navigator-credentials.serivce";
import { MainDesktopAutotypeService } from "./autofill/main/main-desktop-autotype.service";
import { MainSshAgentService } from "./autofill/main/main-ssh-agent.service";
import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service";
@@ -284,6 +285,7 @@ export class Main {
app.getPath("exe"),
app.getAppPath(),
);
new MainNavigatorCredentialsService();
this.desktopAutofillSettingsService = new DesktopAutofillSettingsService(stateProvider);

View File

@@ -62,7 +62,9 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { NavigatorCredentialsService } from "@bitwarden/common/auth/abstractions/webauthn/navigator-credentials.service";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { DefaultNavigatorCredentialsService } from "@bitwarden/common/auth/services/webauthn-login/default-navigator-credentials.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ClientType } from "@bitwarden/common/enums";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
@@ -469,6 +471,11 @@ const safeProviders: SafeProvider[] = [
useClass: WebSystemService,
deps: [],
}),
safeProvider({
provide: NavigatorCredentialsService,
useClass: DefaultNavigatorCredentialsService,
deps: [WINDOW, PlatformUtilsService],
}),
safeProvider({
provide: SessionTimeoutSettingsComponentService,
useClass: WebSessionTimeoutSettingsComponentService,

View File

@@ -105,6 +105,7 @@ import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction";
import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { NavigatorCredentialsService } from "@bitwarden/common/auth/abstractions/webauthn/navigator-credentials.service";
import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction";
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
@@ -1346,7 +1347,7 @@ const safeProviders: SafeProvider[] = [
WebAuthnLoginApiServiceAbstraction,
LoginStrategyServiceAbstraction,
WebAuthnLoginPrfKeyServiceAbstraction,
WINDOW,
NavigatorCredentialsService,
LogService,
],
}),

View File

@@ -23,10 +23,6 @@ export class DefaultLoginComponentService implements LoginComponentService {
this.clientType = this.platformUtilsService.getClientType();
}
isLoginWithPasskeySupported(): boolean {
return this.clientType === ClientType.Web;
}
/**
* Redirects the user to the SSO login page, either via route or in a new browser window.
* @param email The email address of the user attempting to log in

View File

@@ -25,11 +25,6 @@ export abstract class LoginComponentService {
*/
getOrgPoliciesFromOrgInvite?: (email: string) => Promise<PasswordPolicies | null>;
/**
* Indicates whether login with passkey is supported on the given client
*/
isLoginWithPasskeySupported: () => boolean;
/**
* Redirects the user to the SSO login page, either via route or in a new browser window.
*/

View File

@@ -27,6 +27,7 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { WebauthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn-login/webauthn-login.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -146,6 +147,7 @@ export class LoginComponent implements OnInit, OnDestroy {
private configService: ConfigService,
private ssoLoginService: SsoLoginServiceAbstraction,
private environmentService: EnvironmentService,
private webauthnLoginService: WebauthnLoginServiceAbstraction,
) {
this.clientType = this.platformUtilsService.getClientType();
}
@@ -525,7 +527,7 @@ export class LoginComponent implements OnInit, OnDestroy {
}
isLoginWithPasskeySupported() {
return this.loginComponentService.isLoginWithPasskeySupported();
return this.webauthnLoginService.available();
}
protected async goToHint(): Promise<void> {

View File

@@ -0,0 +1,4 @@
export abstract class NavigatorCredentialsService {
abstract get(options: CredentialRequestOptions): Promise<Credential | null>;
abstract available(): Promise<boolean>;
}

View File

@@ -38,4 +38,9 @@ export abstract class WebAuthnLoginServiceAbstraction {
* that needs to be validated for login.
*/
abstract logIn(assertion: WebAuthnLoginCredentialAssertionView): Promise<AuthResult>;
/**
* Checks if WebAuthnLogin is available in the current environment.
*/
abstract available(): Promise<boolean>;
}

View File

@@ -0,0 +1,23 @@
import { ClientType } from "@bitwarden/client-type";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { NavigatorCredentialsServiceAbstraction } from "../../abstractions/webauthn/navigator-credentials.service";
export class DefaultNavigatorCredentialsService implements NavigatorCredentialsServiceAbstraction {
private navigatorCredentials: CredentialsContainer;
constructor(
private window: Window,
private platformUtilsService: PlatformUtilsService,
) {
this.navigatorCredentials = this.window.navigator.credentials;
}
async get(options: CredentialRequestOptions): Promise<Credential | null> {
return await this.navigatorCredentials.get(options);
}
async available(): Promise<boolean> {
return this.platformUtilsService.getClientType() === ClientType.Web;
}
}

View File

@@ -6,6 +6,7 @@ import { LoginStrategyServiceAbstraction, WebAuthnLoginCredentials } from "@bitw
import { LogService } from "../../../platform/abstractions/log.service";
import { PrfKey } from "../../../types/key";
import { NavigatorCredentialsService } from "../../abstractions/webauthn/navigator-credentials.service";
import { WebAuthnLoginApiServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-api.service.abstraction";
import { WebAuthnLoginPrfKeyServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
import { WebAuthnLoginServiceAbstraction } from "../../abstractions/webauthn/webauthn-login.service.abstraction";
@@ -16,17 +17,13 @@ import { WebAuthnLoginCredentialAssertionView } from "../../models/view/webauthn
import { WebAuthnLoginAssertionResponseRequest } from "./request/webauthn-login-assertion-response.request";
export class WebAuthnLoginService implements WebAuthnLoginServiceAbstraction {
private navigatorCredentials: CredentialsContainer;
constructor(
private webAuthnLoginApiService: WebAuthnLoginApiServiceAbstraction,
private loginStrategyService: LoginStrategyServiceAbstraction,
private webAuthnLoginPrfKeyService: WebAuthnLoginPrfKeyServiceAbstraction,
private window: Window,
private logService?: LogService,
) {
this.navigatorCredentials = this.window.navigator.credentials;
}
protected loginStrategyService: LoginStrategyServiceAbstraction,
protected webAuthnLoginPrfKeyService: WebAuthnLoginPrfKeyServiceAbstraction,
protected navigatorCredentialsService: NavigatorCredentialsService,
protected logService?: LogService,
) {}
async getCredentialAssertionOptions(): Promise<WebAuthnLoginCredentialAssertionOptionsView> {
const response = await this.webAuthnLoginApiService.getCredentialAssertionOptions();
@@ -45,7 +42,7 @@ export class WebAuthnLoginService implements WebAuthnLoginServiceAbstraction {
} as any;
try {
const response = await this.navigatorCredentials.get(nativeOptions);
const response = await this.navigatorCredentialsService.get(nativeOptions);
if (!(response instanceof PublicKeyCredential)) {
return undefined;
}
@@ -85,4 +82,8 @@ export class WebAuthnLoginService implements WebAuthnLoginServiceAbstraction {
const result = await this.loginStrategyService.logIn(credential);
return result;
}
async available(): Promise<boolean> {
return this.navigatorCredentialsService.available();
}
}