@@ -21,12 +21,12 @@ use std::sync::{Arc, LazyLock};
21
21
use std:: time:: Duration ;
22
22
use url:: Url ;
23
23
use uuid:: Uuid ;
24
- use webauthn_rs:: prelude:: { Base64UrlSafeData , SecurityKey , SecurityKeyAuthentication , SecurityKeyRegistration } ;
24
+ use webauthn_rs:: prelude:: { Base64UrlSafeData , Credential , Passkey , PasskeyAuthentication , PasskeyRegistration } ;
25
25
use webauthn_rs:: { Webauthn , WebauthnBuilder } ;
26
26
use webauthn_rs_proto:: {
27
27
AuthenticationExtensionsClientOutputs , AuthenticatorAssertionResponseRaw , AuthenticatorAttestationResponseRaw ,
28
28
PublicKeyCredential , RegisterPublicKeyCredential , RegistrationExtensionsClientOutputs ,
29
- RequestAuthenticationExtensions ,
29
+ RequestAuthenticationExtensions , UserVerificationPolicy ,
30
30
} ;
31
31
32
32
pub static WEBAUTHN_2FA_CONFIG : LazyLock < Arc < Webauthn > > = LazyLock :: new ( || {
@@ -38,8 +38,7 @@ pub static WEBAUTHN_2FA_CONFIG: LazyLock<Arc<Webauthn>> = LazyLock::new(|| {
38
38
let webauthn = WebauthnBuilder :: new ( & rp_id, & rp_origin)
39
39
. expect ( "Creating WebauthnBuilder failed" )
40
40
. rp_name ( & domain)
41
- . timeout ( Duration :: from_millis ( 60000 ) )
42
- . danger_set_user_presence_only_security_keys ( true ) ;
41
+ . timeout ( Duration :: from_millis ( 60000 ) ) ;
43
42
44
43
Arc :: new ( webauthn. build ( ) . expect ( "Building Webauthn failed" ) )
45
44
} ) ;
@@ -78,7 +77,7 @@ pub struct WebauthnRegistration {
78
77
pub name : String ,
79
78
pub migrated : bool ,
80
79
81
- pub credential : SecurityKey ,
80
+ pub credential : Passkey ,
82
81
}
83
82
84
83
impl WebauthnRegistration {
@@ -89,6 +88,24 @@ impl WebauthnRegistration {
89
88
"migrated" : self . migrated,
90
89
} )
91
90
}
91
+
92
+ fn set_backup_eligible ( & mut self , backup_eligible : bool , backup_state : bool ) -> bool {
93
+ let mut changed = false ;
94
+ let mut cred: Credential = self . credential . clone ( ) . into ( ) ;
95
+
96
+ if cred. backup_state != backup_state {
97
+ cred. backup_state = backup_state;
98
+ changed = true ;
99
+ }
100
+
101
+ if backup_eligible && !cred. backup_eligible {
102
+ cred. backup_eligible = true ;
103
+ changed = true ;
104
+ }
105
+
106
+ self . credential = cred. into ( ) ;
107
+ changed
108
+ }
92
109
}
93
110
94
111
#[ post( "/two-factor/get-webauthn" , data = "<data>" ) ]
@@ -131,18 +148,27 @@ async fn generate_webauthn_challenge(
131
148
. map ( |r| r. credential . cred_id ( ) . to_owned ( ) ) // We return the credentialIds to the clients to avoid double registering
132
149
. collect ( ) ;
133
150
134
- let ( challenge, state) = webauthn. start_securitykey_registration (
151
+ let ( mut challenge, state) = webauthn. start_passkey_registration (
135
152
Uuid :: from_str ( & user. uuid ) . expect ( "Failed to parse UUID" ) , // Should never fail
136
153
& user. email ,
137
154
& user. name ,
138
155
Some ( registrations) ,
139
- None ,
140
- None ,
141
156
) ?;
142
157
158
+ let mut state = serde_json:: to_value ( & state) ?;
159
+ state[ "rs" ] [ "policy" ] = Value :: String ( "discouraged" . to_string ( ) ) ;
160
+ state[ "rs" ] [ "extensions" ] . as_object_mut ( ) . unwrap ( ) . clear ( ) ;
161
+
143
162
let type_ = TwoFactorType :: WebauthnRegisterChallenge ;
144
163
TwoFactor :: new ( user. uuid . clone ( ) , type_, serde_json:: to_string ( & state) ?) . save ( & mut conn) . await ?;
145
164
165
+ // Because for this flow we abuse the passkeys as 2FA, and use it more like a securitykey
166
+ // we need to modify some of the default settings defined by `start_passkey_registration()`.
167
+ challenge. public_key . extensions = None ;
168
+ if let Some ( asc) = challenge. public_key . authenticator_selection . as_mut ( ) {
169
+ asc. user_verification = UserVerificationPolicy :: Discouraged_DO_NOT_USE ;
170
+ }
171
+
146
172
let mut challenge_value = serde_json:: to_value ( challenge. public_key ) ?;
147
173
challenge_value[ "status" ] = "ok" . into ( ) ;
148
174
challenge_value[ "errorMessage" ] = "" . into ( ) ;
@@ -253,15 +279,15 @@ async fn activate_webauthn(
253
279
let type_ = TwoFactorType :: WebauthnRegisterChallenge as i32 ;
254
280
let state = match TwoFactor :: find_by_user_and_type ( & user. uuid , type_, & mut conn) . await {
255
281
Some ( tf) => {
256
- let state: SecurityKeyRegistration = serde_json:: from_str ( & tf. data ) ?;
282
+ let state: PasskeyRegistration = serde_json:: from_str ( & tf. data ) ?;
257
283
tf. delete ( & mut conn) . await ?;
258
284
state
259
285
}
260
286
None => err ! ( "Can't recover challenge" ) ,
261
287
} ;
262
288
263
289
// Verify the credentials with the saved state
264
- let credential = webauthn. finish_securitykey_registration ( & data. device_response . into ( ) , & state) ?;
290
+ let credential = webauthn. finish_passkey_registration ( & data. device_response . into ( ) , & state) ?;
265
291
266
292
let mut registrations: Vec < _ > = get_webauthn_registrations ( & user. uuid , & mut conn) . await ?. 1 ;
267
293
// TODO: Check for repeated ID's
@@ -372,21 +398,25 @@ pub async fn generate_webauthn_login(
372
398
conn : & mut DbConn ,
373
399
) -> JsonResult {
374
400
// Load saved credentials
375
- let creds: Vec < _ > = get_webauthn_registrations ( user_id, conn) . await ?. 1 . into_iter ( ) . map ( |r| r. credential ) . collect ( ) ;
401
+ let creds: Vec < Passkey > =
402
+ get_webauthn_registrations ( user_id, conn) . await ?. 1 . into_iter ( ) . map ( |r| r. credential ) . collect ( ) ;
376
403
377
404
if creds. is_empty ( ) {
378
405
err ! ( "No Webauthn devices registered" )
379
406
}
380
407
381
408
// Generate a challenge based on the credentials
382
- let ( mut response, state) = webauthn. start_securitykey_authentication ( & creds) ?;
409
+ let ( mut response, state) = webauthn. start_passkey_authentication ( & creds) ?;
383
410
384
411
// Modify to discourage user verification
385
412
let mut state = serde_json:: to_value ( & state) ?;
413
+ state[ "ast" ] [ "policy" ] = Value :: String ( "discouraged" . to_string ( ) ) ;
386
414
387
415
// Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well
388
416
let app_id = format ! ( "{}/app-id.json" , & CONFIG . domain( ) ) ;
389
417
state[ "ast" ] [ "appid" ] = Value :: String ( app_id. clone ( ) ) ;
418
+
419
+ response. public_key . user_verification = UserVerificationPolicy :: Discouraged_DO_NOT_USE ;
390
420
response
391
421
. public_key
392
422
. extensions
@@ -413,9 +443,9 @@ pub async fn validate_webauthn_login(
413
443
conn : & mut DbConn ,
414
444
) -> EmptyResult {
415
445
let type_ = TwoFactorType :: WebauthnLoginChallenge as i32 ;
416
- let state = match TwoFactor :: find_by_user_and_type ( user_id, type_, conn) . await {
446
+ let mut state = match TwoFactor :: find_by_user_and_type ( user_id, type_, conn) . await {
417
447
Some ( tf) => {
418
- let state: SecurityKeyAuthentication = serde_json:: from_str ( & tf. data ) ?;
448
+ let state: PasskeyAuthentication = serde_json:: from_str ( & tf. data ) ?;
419
449
tf. delete ( conn) . await ?;
420
450
state
421
451
}
@@ -432,7 +462,12 @@ pub async fn validate_webauthn_login(
432
462
433
463
let mut registrations = get_webauthn_registrations ( user_id, conn) . await ?. 1 ;
434
464
435
- let authentication_result = webauthn. finish_securitykey_authentication ( & rsp, & state) ?;
465
+ // We need to check for and update the backup_eligible flag when needed.
466
+ // Vaultwarden did not have knowledge of this flag prior to migrating to webauthn-rs v0.5.x
467
+ // Because of this we check the flag at runtime and update the registrations and state when needed
468
+ check_and_update_backup_eligible ( user_id, & rsp, & mut registrations, & mut state, conn) . await ?;
469
+
470
+ let authentication_result = webauthn. finish_passkey_authentication ( & rsp, & state) ?;
436
471
437
472
for reg in & mut registrations {
438
473
if ct_eq ( reg. credential . cred_id ( ) , authentication_result. cred_id ( ) ) {
@@ -454,3 +489,66 @@ pub async fn validate_webauthn_login(
454
489
}
455
490
)
456
491
}
492
+
493
+ async fn check_and_update_backup_eligible (
494
+ user_id : & UserId ,
495
+ rsp : & PublicKeyCredential ,
496
+ registrations : & mut Vec < WebauthnRegistration > ,
497
+ state : & mut PasskeyAuthentication ,
498
+ conn : & mut DbConn ,
499
+ ) -> EmptyResult {
500
+ // The feature flags from the response
501
+ // For details see: https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data
502
+ const FLAG_BACKUP_ELIGIBLE : u8 = 0b0000_1000 ;
503
+ const FLAG_BACKUP_STATE : u8 = 0b0001_0000 ;
504
+
505
+ if let Some ( bits) = rsp. response . authenticator_data . get ( 32 ) {
506
+ let backup_eligible = 0 != ( bits & FLAG_BACKUP_ELIGIBLE ) ;
507
+ let backup_state = 0 != ( bits & FLAG_BACKUP_STATE ) ;
508
+
509
+ // If the current key is backup eligible, then we probably need to update one of the keys already stored in the database
510
+ // This is needed because Vaultwarden didn't store this information when using the previous version of webauthn-rs since it was a new addition to the protocol
511
+ // Because we store multiple keys in one json string, we need to fetch the correct key first, and update its information before we let it verify
512
+ if backup_eligible {
513
+ let rsp_id = rsp. raw_id . as_slice ( ) ;
514
+ for reg in & mut * registrations {
515
+ if ct_eq ( reg. credential . cred_id ( ) . as_slice ( ) , rsp_id) {
516
+ // Try to update the key, and if needed also update the database, before the actual state check is done
517
+ if reg. set_backup_eligible ( backup_eligible, backup_state) {
518
+ TwoFactor :: new (
519
+ user_id. clone ( ) ,
520
+ TwoFactorType :: Webauthn ,
521
+ serde_json:: to_string ( & registrations) ?,
522
+ )
523
+ . save ( conn)
524
+ . await ?;
525
+
526
+ // We also need to adjust the current state which holds the challenge used to start the authentication verification
527
+ // Because Vaultwarden supports multiple keys, we need to loop through the deserialized state and check which key to update
528
+ let mut raw_state = serde_json:: to_value ( & state) ?;
529
+ if let Some ( credentials) = raw_state
530
+ . get_mut ( "ast" )
531
+ . and_then ( |v| v. get_mut ( "credentials" ) )
532
+ . and_then ( |v| v. as_array_mut ( ) )
533
+ {
534
+ for cred in credentials. iter_mut ( ) {
535
+ if cred. get ( "cred_id" ) . is_some_and ( |v| {
536
+ // Deserialize to a [u8] so it can be compared using `ct_eq` with the `rsp_id`
537
+ let cred_id_slice: Base64UrlSafeData = serde_json:: from_value ( v. clone ( ) ) . unwrap ( ) ;
538
+ ct_eq ( cred_id_slice, rsp_id)
539
+ } ) {
540
+ cred[ "backup_eligible" ] = Value :: Bool ( backup_eligible) ;
541
+ cred[ "backup_state" ] = Value :: Bool ( backup_state) ;
542
+ }
543
+ }
544
+ }
545
+
546
+ * state = serde_json:: from_value ( raw_state) ?;
547
+ }
548
+ break ;
549
+ }
550
+ }
551
+ }
552
+ }
553
+ Ok ( ( ) )
554
+ }
0 commit comments