From 9e4aa5efe76f78d349492bc36731f2117b488a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:07:00 +0200 Subject: [PATCH] feat: Added support for passkey vault unlock feature Co-authored-by: Keng <39216134+kengzzzz@users.noreply.github.com> --- src/api/core/ciphers.rs | 30 +++++++++++++++++++++++------- src/api/core/mod.rs | 7 ++++++- src/api/core/webauthn.rs | 26 +++++++++++++++++++++++++- src/api/identity.rs | 10 ++++------ src/config.rs | 1 + src/db/models/mod.rs | 2 +- 6 files changed, 60 insertions(+), 16 deletions(-) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 6b9994cf..9437f22f 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -12,9 +12,11 @@ use serde_json::Value; use crate::{ CONFIG, - api::{self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event}, - auth::ClientVersion, - auth::{Headers, OrgIdGuard, OwnerHeaders}, + api::{ + self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + core::{log_event, webauthn_prf_option}, + }, + auth::{ClientVersion, Headers, OrgIdGuard, OwnerHeaders}, config::PathType, crypto, db::{ @@ -22,7 +24,7 @@ use crate::{ models::{ Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership, - MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, + MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, WebauthnCredential, }, }, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, @@ -188,6 +190,12 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option) -> Value { + let mut user_decryption = json!({ + "masterPasswordUnlock": master_password_unlock, + }); + if !webauthn_prf_options.is_empty() { + user_decryption["webAuthnPrfOptions"] = webauthn_prf_options.into(); + } + user_decryption +} + #[get("/ciphers")] async fn get_ciphers(headers: Headers, conn: DbConn) -> JsonResult { let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &conn).await; diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index c28ebae5..fdbed750 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -15,7 +15,7 @@ pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciph pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; pub use events::{event_cleanup_job, log_event, log_user_event}; pub use sends::purge_sends; -pub use webauthn::WEBAUTHN_PASSWORDLESS; +pub use webauthn::{WEBAUTHN_PASSWORDLESS, webauthn_prf_option}; use reqwest::Method; use rocket::{Catcher, Route, serde::json::Json, serde::json::Value}; @@ -211,6 +211,11 @@ fn config() -> Json { &FeatureFlagFilter::ValidOnly, ); feature_states.insert("pm-19148-innovation-archive".to_owned(), true); + if CONFIG.passkey_login_allowed() { + feature_states.insert("pm-2035-passkey-unlock".to_owned(), true); + } else { + feature_states.remove("pm-2035-passkey-unlock"); + } Json(json!({ // Note: The clients use this version to handle backwards compatibility concerns diff --git a/src/api/core/webauthn.rs b/src/api/core/webauthn.rs index d7aedcd9..97c35556 100644 --- a/src/api/core/webauthn.rs +++ b/src/api/core/webauthn.rs @@ -14,7 +14,7 @@ use crate::{ auth::Headers, db::{ DbConn, - models::{TwoFactor, TwoFactorType, WebauthnCredential, WebauthnCredentialId}, + models::{TwoFactor, TwoFactorType, WebauthnCredential, WebauthnCredentialId, WebauthnCredentialPrfStatus}, }, }; @@ -44,6 +44,30 @@ pub fn routes() -> Vec { routes![get_webauthn, post_webauthn, post_webauthn_attestation_options, post_webauthn_delete] } +pub fn webauthn_prf_option(wac: &WebauthnCredential, pascal_case: bool) -> Option { + if !matches!(wac.get_prf_status(), WebauthnCredentialPrfStatus::Enabled) { + return None; + } + + let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; + + if pascal_case { + Some(json!({ + "CredentialId": passkey.cred_id().to_owned(), + "Transports": [], + "EncryptedPrivateKey": wac.encrypted_private_key.as_ref()?, + "EncryptedUserKey": wac.encrypted_user_key.as_ref()?, + })) + } else { + Some(json!({ + "credentialId": passkey.cred_id().to_owned(), + "transports": [], + "encryptedPrivateKey": wac.encrypted_private_key.as_ref()?, + "encryptedUserKey": wac.encrypted_user_key.as_ref()?, + })) + } +} + #[get("/webauthn")] async fn get_webauthn(headers: Headers, conn: DbConn) -> JsonResult { if !CONFIG.passkey_login_allowed() { diff --git a/src/api/identity.rs b/src/api/identity.rs index a6a36794..a727118c 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -23,6 +23,7 @@ use crate::{ authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey, }, + webauthn_prf_option, }, master_password_policy, push::register_push_device, @@ -633,13 +634,10 @@ async fn webauthn_login(data: ConnectData, user_id: &mut Option, conn: & let mut result = authenticated_response(&user, &mut device, auth_tokens, None, conn, ip).await?; - // Add WebAuthnPrfOption if the credential has encrypted keys (PRF-based decryption) - if matched_wac.encrypted_private_key.is_some() && matched_wac.encrypted_user_key.is_some() { + // Add WebAuthnPrfOption if the credential has enabled PRF-based decryption. + if let Some(prf_option) = webauthn_prf_option(matched_wac, true) { let Json(ref mut val) = result; - val["UserDecryptionOptions"]["WebAuthnPrfOption"] = json!({ - "EncryptedPrivateKey": matched_wac.encrypted_private_key, - "EncryptedUserKey": matched_wac.encrypted_user_key, - }); + val["UserDecryptionOptions"]["WebAuthnPrfOption"] = prf_option; } Ok(result) diff --git a/src/config.rs b/src/config.rs index 3bd57e97..db1351f3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1406,6 +1406,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "ssh-agent-v2", // Key Management Team "ssh-key-vault-item", + "pm-2035-passkey-unlock", "pm-25373-windows-biometrics-v2", // Mobile Team "anon-addy-self-host-alias", diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index aa5d2a41..fb499962 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -44,4 +44,4 @@ pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException}; -pub use self::webauthn_credential::{WebauthnCredential, WebauthnCredentialId}; +pub use self::webauthn_credential::{WebauthnCredential, WebauthnCredentialId, WebauthnCredentialPrfStatus};