committed by
GitHub
8 changed files with 529 additions and 26 deletions
@ -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 |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue