Browse Source

Merge 7594c012dc into 32098ca7d1

pull/7728/merge
Minseo Choi 2 days ago
committed by GitHub
parent
commit
66ee2e2bfc
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 105
      scripts/sim-ios-autofill.sh
  2. 1
      src/api/core/mod.rs
  3. 2
      src/api/mod.rs
  4. 127
      src/api/web.rs
  5. 5
      src/db/models/cipher.rs
  6. 305
      src/db/models/cipher_login.rs
  7. 1
      src/db/models/mod.rs
  8. 9
      src/main.rs

105
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

1
src/api/core/mod.rs

@ -220,6 +220,7 @@ fn config() -> Json<Value> {
&FeatureFlagFilter::ValidOnly, &FeatureFlagFilter::ValidOnly,
); );
feature_states.insert("pm-19148-innovation-archive".to_owned(), true); feature_states.insert("pm-19148-innovation-archive".to_owned(), true);
feature_states.insert("pm-30529-webauthn-related-origins".to_owned(), true);
Json(json!({ Json(json!({
// Note: The clients use this version to handle backwards compatibility concerns // Note: The clients use this version to handle backwards compatibility concerns

2
src/api/mod.rs

@ -30,6 +30,8 @@ pub use crate::api::{
}, },
web::catchers as web_catchers, web::catchers as web_catchers,
web::routes as web_routes, web::routes as web_routes,
web::should_mount_origin_root_well_known,
web::well_known_routes,
web::{invalidate_css_cache, static_files}, web::{invalidate_css_cache, static_files},
}; };
use crate::{ use crate::{

127
src/api/web.rs

@ -28,17 +28,17 @@ use crate::{
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
// If adding more routes here, consider also adding them to // If adding more routes here, consider also adding them to
// crate::utils::LOGGED_ROUTES to make sure they appear in the log // 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() { if CONFIG.web_vault_enabled() {
routes.append(&mut routes![ routes.append(&mut routes![web_index, web_index_direct, web_index_head, web_files, vaultwarden_css]);
web_index,
web_index_direct,
web_index_head,
app_id,
apple_app_site_association,
web_files,
vaultwarden_css
]);
} }
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@ -204,22 +204,44 @@ fn app_id() -> Cached<(ContentType, Json<Value>)> {
) )
} }
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")] #[get("/.well-known/apple-app-site-association")]
fn apple_app_site_association() -> Cached<(ContentType, Json<Value>)> { fn apple_app_site_association() -> Cached<(ContentType, Json<Value>)> {
Cached::long( Cached::long((ContentType::JSON, Json(aasa_document())), true)
( }
ContentType::JSON,
Json(json!({ /// W3C Related Origin Requests. iOS Autofill fetches this when
"webcredentials": { /// `pm-30529-webauthn-related-origins` is on.
"apps": [ #[get("/.well-known/webauthn")]
"LTZ2PFU5D6.com.8bit.bitwarden", fn webauthn_related_origins() -> Cached<(ContentType, Json<Value>)> {
"LTZ2PFU5D6.com.8bit.bitwarden.beta" 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<Route> {
true, 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("/<p..>", rank = 10)] // Only match this if the other routes don't match #[get("/<p..>", 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}")), _ => 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<String> = 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")));
}
}

5
src/db/models/cipher.rs

@ -282,6 +282,11 @@ impl Cipher {
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() { if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision)); 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 // Fix secure note issues when data is invalid

305
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<String>,
key_type: Option<String>,
key_algorithm: Option<String>,
key_curve: Option<String>,
key_value: Option<String>,
rp_id: Option<String>,
user_handle: Option<String>,
user_name: Option<String>,
counter: Option<String>,
rp_name: Option<String>,
user_display_name: Option<String>,
discoverable: Option<String>,
creation_date: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SdkLogin {
fido2_credentials: Option<Vec<SdkFido2Credential>>,
}
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::<SdkFido2Credential>(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);
}
}

1
src/db/models/mod.rs

@ -2,6 +2,7 @@ mod archive;
mod attachment; mod attachment;
mod auth_request; mod auth_request;
mod cipher; mod cipher;
mod cipher_login;
mod collection; mod collection;
mod device; mod device;
mod emergency_access; mod emergency_access;

9
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 // If adding more paths here, consider also adding them to
// crate::utils::LOGGED_ROUTES to make sure they appear in the log // 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, "/"].concat(), api::web_routes())
.mount([basepath, "/api"].concat(), api::core_routes()) .mount([basepath, "/api"].concat(), api::core_routes())
.mount([basepath, "/admin"].concat(), api::admin_routes()) .mount([basepath, "/admin"].concat(), api::admin_routes())
.mount([basepath, "/events"].concat(), api::core_events_routes()) .mount([basepath, "/events"].concat(), api::core_events_routes())
.mount([basepath, "/identity"].concat(), api::identity_routes()) .mount([basepath, "/identity"].concat(), api::identity_routes())
.mount([basepath, "/icons"].concat(), api::icons_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, "/"].concat(), api::web_catchers())
.register([basepath, "/api"].concat(), api::core_catchers()) .register([basepath, "/api"].concat(), api::core_catchers())
.register([basepath, "/admin"].concat(), api::admin_catchers()) .register([basepath, "/admin"].concat(), api::admin_catchers())

Loading…
Cancel
Save