diff --git a/scripts/sim-ios-autofill.sh b/scripts/sim-ios-autofill.sh new file mode 100755 index 00000000..8f7efed1 --- /dev/null +++ b/scripts/sim-ios-autofill.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# iOS Autofill simulator / test environment for PR #7728. +# +# Two layers: +# 1. cargo tests — SDK deny_unknown_fields + Rocket local HTTP for well-known +# 2. live binary — WEB_VAULT_ENABLED=false, DOMAIN with a path prefix, curl origin-root +# +# Usage: +# ./scripts/sim-ios-autofill.sh # unit + HTTP tests +# ./scripts/sim-ios-autofill.sh --live # also boot sqlite VW and curl well-known +set -euo pipefail +cd "$(dirname "$0")/.." + +FEATURES="${TDD_FEATURES:-sqlite}" +LIVE=0 +if [[ "${1:-}" == "--live" ]]; then + LIVE=1 +fi + +echo "==> SDK + Rocket well-known simulator" +cargo test --features "${FEATURES}" --bins sdk_simulator -- --nocapture +cargo test --features "${FEATURES}" --bins sdk_has_fido2 -- --nocapture +cargo test --features "${FEATURES}" --bins ios_autofill_sim -- --nocapture + +if [[ "${LIVE}" -ne 1 ]]; then + echo "==> skip live server (pass --live to curl a launched binary)" + exit 0 +fi + +PORT="${SIM_PORT:-18282}" +DATA="$(mktemp -d "${TMPDIR:-/tmp}/vw-ios-autofill.XXXXXX")" +cleanup() { + if [[ -n "${VW_PID:-}" ]] && kill -0 "${VW_PID}" 2>/dev/null; then + kill "${VW_PID}" 2>/dev/null || true + wait "${VW_PID}" 2>/dev/null || true + fi + rm -rf "${DATA}" +} +trap cleanup EXIT + +echo "==> build sqlite binary" +cargo build --features "${FEATURES}" --bin vaultwarden + +echo "==> launch WEB_VAULT_ENABLED=false DOMAIN=.../vw on :${PORT}" +export DATA_FOLDER="${DATA}" +export DATABASE_URL="sqlite://${DATA}/db.sqlite3" +export WEB_VAULT_ENABLED=false +export DOMAIN="http://127.0.0.1:${PORT}/vw" +export ROCKET_ADDRESS=127.0.0.1 +export ROCKET_PORT="${PORT}" +export DISABLE_ADMIN_TOKEN=true +export I_REALLY_WANT_VOLATILE_STORAGE=true +export LOG_LEVEL=warn + +cargo run --features "${FEATURES}" --bin vaultwarden >/dev/null 2>"${DATA}/vw.log" & +VW_PID=$! + +ok=0 +for _ in $(seq 1 60); do + if curl -fsS "http://127.0.0.1:${PORT}/vw/alive" >/dev/null 2>&1; then + ok=1 + break + fi + if ! kill -0 "${VW_PID}" 2>/dev/null; then + echo "vaultwarden exited early:" >&2 + cat "${DATA}/vw.log" >&2 || true + exit 1 + fi + sleep 0.5 +done +if [[ "${ok}" -ne 1 ]]; then + echo "timed out waiting for /vw/alive" >&2 + cat "${DATA}/vw.log" >&2 || true + exit 1 +fi + +python3 - "${PORT}" <<'PY' +import json, sys, urllib.request + +port = sys.argv[1] +base = f"http://127.0.0.1:{port}" + +def get(path): + with urllib.request.urlopen(base + path) as res: + return res.status, json.load(res) + +status, aasa_root = get("/.well-known/apple-app-site-association") +assert status == 200, status +apps = aasa_root["webcredentials"]["apps"] +assert "LTZ2PFU5D6.com.8bit.bitwarden.autofill" in apps, apps + +status, aasa_path = get("/vw/.well-known/apple-app-site-association") +assert status == 200, status +assert aasa_path == aasa_root + +status, webauthn = get("/.well-known/webauthn") +assert status == 200, status +assert webauthn.get("origins"), webauthn + +status, webauthn_path = get("/vw/.well-known/webauthn") +assert status == 200, status +assert webauthn_path == webauthn + +print("live HTTP: AASA + related-origins OK at / and /vw (web vault off)") +PY 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..ce6af051 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -30,6 +30,8 @@ pub use crate::api::{ }, web::catchers as web_catchers, web::routes as web_routes, + web::should_mount_origin_root_well_known, + 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..cf99f934 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)] @@ -204,22 +204,44 @@ fn app_id() -> Cached<(ContentType, Json)> { ) } +fn aasa_document() -> Value { + json!({ + "webcredentials": { + "apps": [ + "LTZ2PFU5D6.com.8bit.bitwarden", + "LTZ2PFU5D6.com.8bit.bitwarden.beta", + "LTZ2PFU5D6.com.8bit.bitwarden.autofill" + ] + } + }) +} + +fn related_origins_document() -> Value { + json!({ + "origins": [CONFIG.domain_origin()] + }) +} + #[get("/.well-known/apple-app-site-association")] fn apple_app_site_association() -> Cached<(ContentType, Json)> { - Cached::long( - ( - ContentType::JSON, - Json(json!({ - "webcredentials": { - "apps": [ - "LTZ2PFU5D6.com.8bit.bitwarden", - "LTZ2PFU5D6.com.8bit.bitwarden.beta" - ] - } - })), - ), - true, - ) + Cached::long((ContentType::JSON, Json(aasa_document())), true) +} + +/// 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(related_origins_document())), 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] +} + +/// Apple only fetches `/.well-known/*` at the origin root. Dual-mount when DOMAIN has a path. +pub fn should_mount_origin_root_well_known(basepath: &str) -> bool { + !basepath.is_empty() } #[get("/", rank = 10)] // Only match this if the other routes don't match @@ -304,3 +326,60 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro _ => err!(format!("Static file not found: {filename}")), } } + +#[cfg(test)] +mod ios_autofill_sim { + use super::*; + use rocket::{http::Status, local::blocking::Client}; + + fn well_known_client() -> Client { + Client::tracked(rocket::build().mount("/", well_known_routes())).expect("rocket well-known client") + } + + #[test] + fn aasa_includes_autofill_extension() { + let document = aasa_document(); + let apps = document["webcredentials"]["apps"].as_array().expect("apps"); + assert!(apps.iter().any(|app| app == "LTZ2PFU5D6.com.8bit.bitwarden.autofill")); + } + + #[test] + fn related_origins_lists_vault_origin() { + let document = related_origins_document(); + let origins = document["origins"].as_array().expect("origins"); + assert_eq!(origins.len(), 1); + assert!(origins[0].as_str().is_some_and(|o| !o.is_empty())); + } + + #[test] + fn origin_root_mount_when_domain_has_path() { + assert!(should_mount_origin_root_well_known("/vw")); + assert!(!should_mount_origin_root_well_known("")); + } + + #[test] + fn aasa_http_includes_autofill_app_id() { + let client = well_known_client(); + let res = client.get("/.well-known/apple-app-site-association").dispatch(); + assert_eq!(res.status(), Status::Ok); + let body: Value = res.into_json().expect("aasa json"); + let apps = body["webcredentials"]["apps"].as_array().expect("apps"); + assert!(apps.iter().any(|app| app == "LTZ2PFU5D6.com.8bit.bitwarden.autofill")); + } + + #[test] + fn webauthn_http_lists_related_origins() { + let client = well_known_client(); + let res = client.get("/.well-known/webauthn").dispatch(); + assert_eq!(res.status(), Status::Ok); + let body: Value = res.into_json().expect("webauthn json"); + assert!(body["origins"].as_array().is_some_and(|o| !o.is_empty())); + } + + #[test] + fn web_routes_always_include_passkey_well_known() { + let uris: Vec = routes().iter().map(|route| format!("{}", route.uri)).collect(); + assert!(uris.iter().any(|uri| uri.contains("apple-app-site-association"))); + assert!(uris.iter().any(|uri| uri.contains("webauthn"))); + } +} diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 4a8ba1c1..257f1e14 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..bcf350b3 --- /dev/null +++ b/src/db/models/cipher_login.rs @@ -0,0 +1,305 @@ +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::Deserialize; + 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!([])); + } + + /// Mirrors Bitwarden SDK 3 `Fido2Credential` (`deny_unknown_fields`). + /// iOS Autofill maps a deserialize InnerError here to CTAP2 VendorError(240). + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + #[allow(dead_code)] + struct SdkFido2Credential { + credential_id: Option, + key_type: Option, + key_algorithm: Option, + key_curve: Option, + key_value: Option, + rp_id: Option, + user_handle: Option, + user_name: Option, + counter: Option, + rp_name: Option, + user_display_name: Option, + discoverable: Option, + creation_date: Option, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + struct SdkLogin { + fido2_credentials: Option>, + } + + fn extra_key_credential() -> Value { + json!({ + "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", + "prf": {"enabled": true}, + "transports": ["internal"], + "backupEligible": true + }) + } + + #[test] + fn sdk_simulator_rejects_extra_keys_like_ios_vendor_error_240() { + let err = serde_json::from_value::(extra_key_credential()).unwrap_err(); + assert!( + err.to_string().contains("unknown field"), + "SDK deny_unknown_fields must fail on prf/transports/backupEligible: {err}" + ); + } + + #[test] + fn sdk_simulator_accepts_projected_credentials() { + let out = normalize_login_type_data(json!({ + "username": "enc", + "fido2Credentials": [extra_key_credential()] + })); + let login: SdkLogin = serde_json::from_value(out).expect("projected login must deserialize"); + assert!(login.fido2_credentials.as_ref().is_some_and(|creds| creds.len() == 1)); + } + + #[test] + fn sdk_has_fido2_is_none_after_empty_array_is_normalized() { + let out = normalize_login_type_data(json!({"fido2Credentials": []})); + let login: SdkLogin = serde_json::from_value(out).expect("login"); + assert!( + login.fido2_credentials.is_none(), + "SDK has_fido2 is is_some(); [] would mark every login as a passkey" + ); + } + + #[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..585d78a2 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 api::should_mount_origin_root_well_known(basepath) { + 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())