From 7594c012dc2b3ff702d75f2fae097c42c72f294c Mon Sep 17 00:00:00 2001 From: Bob Date: Sat, 19 Sep 2026 20:58:10 -0400 Subject: [PATCH] test: add iOS Autofill SDK and well-known simulator Simulate Bitwarden SDK 3 deny_unknown_fields against projected FIDO2 JSON, hit AASA/related-origins over Rocket local HTTP, and provide a live sqlite launcher that dual-mounts well-known at / and /vw with the web vault disabled. --- scripts/sim-ios-autofill.sh | 105 +++++++++++++++++++++++++++++++++ src/api/mod.rs | 1 + src/api/web.rs | 106 ++++++++++++++++++++++++++-------- src/db/models/cipher_login.rs | 78 +++++++++++++++++++++++++ src/main.rs | 2 +- 5 files changed, 267 insertions(+), 25 deletions(-) create mode 100755 scripts/sim-ios-autofill.sh 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/mod.rs b/src/api/mod.rs index 00daf020..ce6af051 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::should_mount_origin_root_well_known, web::well_known_routes, web::{invalidate_css_cache, static_files}, }; diff --git a/src/api/web.rs b/src/api/web.rs index 086cf21e..cf99f934 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -204,38 +204,34 @@ 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", - "LTZ2PFU5D6.com.8bit.bitwarden.autofill" - ] - } - })), - ), - 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(json!({ - "origins": [CONFIG.domain_origin()] - })), - ), - true, - ) + Cached::long((ContentType::JSON, Json(related_origins_document())), true) } /// Origin-root well-known for Apple AASA / related-origins when DOMAIN has a path prefix. @@ -243,6 +239,11 @@ 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 async fn web_files(p: PathBuf) -> Cached> { Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true) @@ -325,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_login.rs b/src/db/models/cipher_login.rs index 45152d49..bcf350b3 100644 --- a/src/db/models/cipher_login.rs +++ b/src/db/models/cipher_login.rs @@ -81,6 +81,7 @@ fn project_object_keys(value: &Value, allow: &[&str]) -> Value { #[cfg(test)] mod tests { use super::*; + use serde::Deserialize; use serde_json::json; #[test] @@ -198,6 +199,83 @@ mod tests { 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!({ diff --git a/src/main.rs b/src/main.rs index a1a1d7ff..585d78a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -588,7 +588,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> .mount([basepath, "/icons"].concat(), api::icons_routes()) .mount([basepath, "/notifications"].concat(), api::notifications_routes()); // Apple associated-domains and related-origins are origin-root only. - if !basepath.is_empty() { + if api::should_mount_origin_root_well_known(basepath) { instance = instance.mount("/", api::well_known_routes()); } let instance = instance