diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index e6a184dd..9be04956 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -220,6 +220,7 @@ fn config() -> Json { &FeatureFlagFilter::ValidOnly, ); feature_states.insert("pm-19148-innovation-archive".to_owned(), true); + feature_states.insert("pm-30529-webauthn-related-origins".to_owned(), true); Json(json!({ // Note: The clients use this version to handle backwards compatibility concerns diff --git a/src/api/mod.rs b/src/api/mod.rs index 9a79ce95..00daf020 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -30,6 +30,7 @@ pub use crate::api::{ }, web::catchers as web_catchers, web::routes as web_routes, + web::well_known_routes, web::{invalidate_css_cache, static_files}, }; use crate::{ diff --git a/src/api/web.rs b/src/api/web.rs index d6d8d62c..086cf21e 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -28,17 +28,17 @@ use crate::{ pub fn routes() -> Vec { // If adding more routes here, consider also adding them to // crate::utils::LOGGED_ROUTES to make sure they appear in the log - let mut routes = routes![attachments, alive, alive_head, static_files]; + let mut routes = routes![ + attachments, + alive, + alive_head, + static_files, + app_id, + apple_app_site_association, + webauthn_related_origins + ]; if CONFIG.web_vault_enabled() { - routes.append(&mut routes![ - web_index, - web_index_direct, - web_index_head, - app_id, - apple_app_site_association, - web_files, - vaultwarden_css - ]); + routes.append(&mut routes![web_index, web_index_direct, web_index_head, web_files, vaultwarden_css]); } #[cfg(debug_assertions)] @@ -213,7 +213,8 @@ fn apple_app_site_association() -> Cached<(ContentType, Json)> { "webcredentials": { "apps": [ "LTZ2PFU5D6.com.8bit.bitwarden", - "LTZ2PFU5D6.com.8bit.bitwarden.beta" + "LTZ2PFU5D6.com.8bit.bitwarden.beta", + "LTZ2PFU5D6.com.8bit.bitwarden.autofill" ] } })), @@ -222,6 +223,26 @@ fn apple_app_site_association() -> Cached<(ContentType, Json)> { ) } +/// W3C Related Origin Requests. iOS Autofill fetches this when +/// `pm-30529-webauthn-related-origins` is on. +#[get("/.well-known/webauthn")] +fn webauthn_related_origins() -> Cached<(ContentType, Json)> { + Cached::long( + ( + ContentType::JSON, + Json(json!({ + "origins": [CONFIG.domain_origin()] + })), + ), + true, + ) +} + +/// Origin-root well-known for Apple AASA / related-origins when DOMAIN has a path prefix. +pub fn well_known_routes() -> Vec { + routes![apple_app_site_association, webauthn_related_origins] +} + #[get("/", rank = 10)] // Only match this if the other routes don't match async fn web_files(p: PathBuf) -> Cached> { Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true) diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 721d9790..914d4f2d 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -282,6 +282,11 @@ impl Cipher { if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() { type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision)); } + + // Official Bitwarden projects FIDO2 objects onto CipherLoginFido2CredentialData. + // Extra keys (prf, transports, backupEligible) make SDK 3 `Fido2Credential` + // (`deny_unknown_fields`) fail find_credentials as CTAP2 VendorError(240). + type_data_json = super::cipher_login::normalize_login_type_data(type_data_json); } // Fix secure note issues when data is invalid diff --git a/src/db/models/cipher_login.rs b/src/db/models/cipher_login.rs new file mode 100644 index 00000000..45152d49 --- /dev/null +++ b/src/db/models/cipher_login.rs @@ -0,0 +1,227 @@ +use serde_json::{Map, Value}; + +use crate::util::validate_and_format_date; + +/// Bitwarden SDK 3.x `Fido2Credential` deserializes with `deny_unknown_fields`. +/// Official Bitwarden projects login FIDO2 objects onto this allowlist; +/// echoing unknown keys makes iOS Autofill `get_assertion` fail as +/// `Ctap2(Vendor(VendorError(240)))` (SDK maps `find_credentials` errors to 0xF0). +const FIDO2_CREDENTIAL_KEYS: &[&str] = &[ + "credentialId", + "keyType", + "keyAlgorithm", + "keyCurve", + "keyValue", + "rpId", + "userHandle", + "userName", + "counter", + "rpName", + "userDisplayName", + "discoverable", + "creationDate", +]; + +const LOGIN_URI_KEYS: &[&str] = &["uri", "match", "uriChecksum"]; + +/// Normalize a login cipher `data` object for client/SDK consumption. +pub fn normalize_login_type_data(mut data: Value) -> Value { + if !data.is_object() { + return data; + } + + // Official Bitwarden uses a nullable array. SDK `has_fido2` is + // `fido2_credentials.is_some()`, so `[]` would mark every login as a passkey. + let fido2 = match data.get("fido2Credentials") { + Some(Value::Array(creds)) if !creds.is_empty() && creds.iter().all(Value::is_object) => { + Value::Array(creds.iter().map(normalize_fido2_credential).collect()) + } + _ => Value::Null, + }; + data["fido2Credentials"] = fido2; + + if data.get("autofillOnPageLoad").is_none() { + data["autofillOnPageLoad"] = Value::Null; + } + + if let Some(Value::Array(uris)) = data.get_mut("uris") { + for uri in uris.iter_mut() { + *uri = project_object_keys(uri, LOGIN_URI_KEYS); + } + } + + data +} + +const EPOCH_RFC3339: &str = "1970-01-01T00:00:00.000000Z"; + +fn normalize_fido2_credential(cred: &Value) -> Value { + let mut projected = project_object_keys(cred, FIDO2_CREDENTIAL_KEYS); + let creation = match projected.get("creationDate") { + Some(Value::String(raw)) => validate_and_format_date(raw), + _ => EPOCH_RFC3339.to_owned(), + }; + projected["creationDate"] = Value::String(creation); + projected +} + +fn project_object_keys(value: &Value, allow: &[&str]) -> Value { + let Some(obj) = value.as_object() else { + return value.clone(); + }; + let mut out = Map::new(); + for key in allow { + if let Some(v) = obj.get(*key) { + out.insert((*key).to_owned(), v.clone()); + } + } + Value::Object(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn missing_fido2_credentials_becomes_null() { + let out = normalize_login_type_data(json!({ + "username": "enc", + "password": "enc" + })); + assert_eq!(out["fido2Credentials"], Value::Null); + assert_eq!(out["autofillOnPageLoad"], Value::Null); + } + + #[test] + fn strips_unknown_fido2_keys_that_break_ios_sdk() { + let out = normalize_login_type_data(json!({ + "fido2Credentials": [{ + "credentialId": "enc-id", + "keyType": "enc-type", + "keyAlgorithm": "enc-alg", + "keyCurve": "enc-curve", + "keyValue": "enc-key", + "rpId": "enc-rp", + "counter": "enc-counter", + "discoverable": "enc-disc", + "creationDate": "2024-06-07T14:12:36.150Z", + "prf": {"enabled": true}, + "transports": ["internal"], + "backupEligible": true + }] + })); + let cred = &out["fido2Credentials"][0]; + assert_eq!(cred["credentialId"], "enc-id"); + assert_eq!(cred["keyValue"], "enc-key"); + assert!(cred.get("prf").is_none(), "unknown keys must be stripped for SDK deny_unknown_fields"); + assert!(cred.get("transports").is_none()); + assert!(cred.get("backupEligible").is_none()); + } + + #[test] + fn normalizes_fido2_creation_date_to_rfc3339() { + let out = normalize_login_type_data(json!({ + "fido2Credentials": [{ + "credentialId": "enc-id", + "creationDate": "2024-06-07T14:12:36.150Z" + }] + })); + assert_eq!(out["fido2Credentials"][0]["creationDate"], "2024-06-07T14:12:36.150000Z"); + } + + #[test] + fn strips_unknown_uri_keys() { + let out = normalize_login_type_data(json!({ + "uris": [{ + "uri": "enc-uri", + "match": 0, + "uriChecksum": "enc-cs", + "extra": "nope" + }] + })); + let uri = &out["uris"][0]; + assert_eq!(uri["uri"], "enc-uri"); + assert_eq!(uri["match"], 0); + assert_eq!(uri["uriChecksum"], "enc-cs"); + assert!(uri.get("extra").is_none()); + } + + #[test] + fn null_fido2_credentials_stays_null() { + let out = normalize_login_type_data(json!({ + "fido2Credentials": null + })); + assert_eq!(out["fido2Credentials"], Value::Null); + } + + #[test] + fn stored_empty_fido2_array_becomes_null() { + let out = normalize_login_type_data(json!({ + "fido2Credentials": [] + })); + assert_eq!(out["fido2Credentials"], Value::Null); + } + + #[test] + fn malformed_stored_fido2_credentials_become_null() { + let out = normalize_login_type_data(json!({ + "fido2Credentials": [7] + })); + assert_eq!(out["fido2Credentials"], Value::Null); + } + + #[test] + fn missing_creation_date_defaults_to_epoch() { + let out = normalize_login_type_data(json!({ + "fido2Credentials": [{ + "credentialId": "enc-id" + }] + })); + assert_eq!(out["fido2Credentials"][0]["creationDate"], EPOCH_RFC3339); + } + + #[test] + fn non_string_creation_date_defaults_to_epoch() { + let out = normalize_login_type_data(json!({ + "fido2Credentials": [{ + "credentialId": "enc-id", + "creationDate": 1717769556 + }] + })); + assert_eq!(out["fido2Credentials"][0]["creationDate"], EPOCH_RFC3339); + } + + #[test] + fn non_object_login_data_is_unchanged() { + assert_eq!(normalize_login_type_data(Value::Null), Value::Null); + assert_eq!(normalize_login_type_data(json!([])), json!([])); + } + + #[test] + fn normalize_is_idempotent_for_sdk_shaped_credentials() { + let input = json!({ + "username": "enc", + "fido2Credentials": [{ + "credentialId": "enc-id", + "keyType": "enc-type", + "keyAlgorithm": "enc-alg", + "keyCurve": "enc-curve", + "keyValue": "enc-key", + "rpId": "enc-rp", + "userHandle": "enc-uh", + "userName": "enc-un", + "counter": "enc-c", + "rpName": "enc-rn", + "userDisplayName": "enc-dn", + "discoverable": "enc-d", + "creationDate": "2024-06-07T14:12:36.150000Z" + }], + "autofillOnPageLoad": false + }); + let once = normalize_login_type_data(input.clone()); + let twice = normalize_login_type_data(once.clone()); + assert_eq!(once, twice); + assert_eq!(once["fido2Credentials"][0].as_object().unwrap().len(), 13); + } +} diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0e4073a5..e90f8f41 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -2,6 +2,7 @@ mod archive; mod attachment; mod auth_request; mod cipher; +mod cipher_login; mod collection; mod device; mod emergency_access; diff --git a/src/main.rs b/src/main.rs index 437354af..a1a1d7ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -579,14 +579,19 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> // If adding more paths here, consider also adding them to // crate::utils::LOGGED_ROUTES to make sure they appear in the log - let instance = rocket::custom(config) + let mut instance = rocket::custom(config) .mount([basepath, "/"].concat(), api::web_routes()) .mount([basepath, "/api"].concat(), api::core_routes()) .mount([basepath, "/admin"].concat(), api::admin_routes()) .mount([basepath, "/events"].concat(), api::core_events_routes()) .mount([basepath, "/identity"].concat(), api::identity_routes()) .mount([basepath, "/icons"].concat(), api::icons_routes()) - .mount([basepath, "/notifications"].concat(), api::notifications_routes()) + .mount([basepath, "/notifications"].concat(), api::notifications_routes()); + // Apple associated-domains and related-origins are origin-root only. + if !basepath.is_empty() { + instance = instance.mount("/", api::well_known_routes()); + } + let instance = instance .register([basepath, "/"].concat(), api::web_catchers()) .register([basepath, "/api"].concat(), api::core_catchers()) .register([basepath, "/admin"].concat(), api::admin_catchers())