diff --git a/docs/DOMAIN_EXPANSION.md b/docs/DOMAIN_EXPANSION.md index b86c773..954bc45 100644 --- a/docs/DOMAIN_EXPANSION.md +++ b/docs/DOMAIN_EXPANSION.md @@ -19,9 +19,11 @@ This document is the operator playbook for that work. ## Step 1: list the domains in `~/.apw/config.json` -Add (or update) the `supportedDomains` array in the user config. The -field is validated against the bundle's `Associated Domains` entitlement -at runtime, so it cannot claim more domains than the app is entitled to. +Add (or update) the `supportedDomains` array in the user config. `apw doctor` +uses this field to probe each domain's AASA file before a production rollout. +The app still cannot return credentials for domains missing from the signed +`Associated Domains` entitlement, so config may narrow the active set but cannot +expand beyond the shipped entitlement. ```json { @@ -93,10 +95,12 @@ apw app install ## Step 5: verify with `apw doctor` -Run `apw doctor --json` after install. The `app.frameworks` block -reports the entitlement domains the bundle was signed with, and the -`environment` array (issue #12) probes reachability of each AASA file -under `app.aasa[]`. Any check that fails surfaces a remediation hint. +Run `apw doctor --json` after install. The `environment` array includes an +`associated-domains` check when `supportedDomains` is configured. The probe +fails domains whose AASA response redirects, is not served as +`application/json`, or does not include `webcredentials.apps`. Any check that +fails surfaces a remediation hint. CI or one-off validation can override the +config with a comma-separated `APW_AASA_DOMAINS` value. ## Long-term plan diff --git a/rust/src/client.rs b/rust/src/client.rs index f11aaa4..fc4833b 100644 --- a/rust/src/client.rs +++ b/rust/src/client.rs @@ -2678,6 +2678,7 @@ mod tests { username: "alice".to_string(), shared_key: String::new(), secret_source: Some(SecretSource::File), + supported_domains: Vec::new(), fallback_provider: None, fallback_provider_path: None, fallback_provider_timeout_ms: None, @@ -2772,6 +2773,7 @@ mod tests { username: "alice".to_string(), shared_key: String::new(), secret_source: Some(SecretSource::File), + supported_domains: Vec::new(), fallback_provider: None, fallback_provider_path: None, fallback_provider_timeout_ms: None, diff --git a/rust/src/doctor.rs b/rust/src/doctor.rs index a5b17a1..209647e 100644 --- a/rust/src/doctor.rs +++ b/rust/src/doctor.rs @@ -247,17 +247,88 @@ fn check_native_app_bundle() -> DoctorCheck { .with_remediation("Run `./scripts/build-native-app.sh`, then `apw app install`.") } -/// Probe each configured associated domain for a reachable AASA file. -/// Domains are read from `APW_AASA_DOMAINS` (comma-separated) so this can -/// be wired ahead of the `supportedDomains` config field landing. See -/// issue #8. -fn check_associated_domains() -> Option { - let raw = std::env::var("APW_AASA_DOMAINS").ok()?; - let domains: Vec<&str> = raw - .split(',') +fn parse_domain_list(raw: &str) -> Vec { + raw.split(',') .map(str::trim) - .filter(|s| !s.is_empty()) - .collect(); + .filter(|domain| !domain.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn configured_associated_domains() -> Vec { + if let Ok(raw) = std::env::var("APW_AASA_DOMAINS") { + return parse_domain_list(&raw); + } + crate::utils::configured_supported_domains_non_destructive() +} + +fn parse_http_status(line: &str) -> Option { + let mut parts = line.split_whitespace(); + let protocol = parts.next()?; + if !protocol.starts_with("HTTP/") { + return None; + } + parts.next()?.parse().ok() +} + +fn has_valid_aasa_content_type(headers: &str) -> bool { + headers.lines().any(|line| { + let Some((name, value)) = line.split_once(':') else { + return false; + }; + name.trim().eq_ignore_ascii_case("content-type") + && value + .trim() + .split(';') + .next() + .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json")) + }) +} + +fn has_redirect_header(headers: &str) -> bool { + headers.lines().any(|line| { + line.split_once(':') + .is_some_and(|(name, _)| name.trim().eq_ignore_ascii_case("location")) + }) +} + +fn split_curl_response(response: &str) -> Option<(&str, &str)> { + response + .split_once("\r\n\r\n") + .or_else(|| response.split_once("\n\n")) +} + +fn aasa_response_is_valid(response: &str) -> bool { + let Some((headers, body)) = split_curl_response(response) else { + return false; + }; + let status = headers.lines().rev().find_map(parse_http_status); + if !matches!(status, Some(200..=299)) || has_redirect_header(headers) { + return false; + } + if !has_valid_aasa_content_type(headers) { + return false; + } + + let Ok(json) = serde_json::from_str::(body.trim()) else { + return false; + }; + json.pointer("/webcredentials/apps") + .and_then(Value::as_array) + .is_some_and(|apps| { + !apps.is_empty() + && apps + .iter() + .all(|app| app.as_str().is_some_and(|value| !value.trim().is_empty())) + }) +} + +/// Probe each configured associated domain for a valid AASA file. +/// Domains come from `supportedDomains` in `~/.apw/config.json`. The +/// `APW_AASA_DOMAINS` environment variable remains a comma-separated override +/// for CI and one-off operator validation. See issue #8. +fn check_associated_domains() -> Option { + let domains = configured_associated_domains(); if domains.is_empty() { return None; } @@ -265,10 +336,10 @@ fn check_associated_domains() -> Option { let mut failures: Vec = Vec::new(); for domain in &domains { let url = format!("https://{domain}/.well-known/apple-app-site-association"); - // `curl -fsI` is a small dependency footprint — most macOS / Linux - // hosts have it available, and we just need a HEAD probe. - let probe = run_probe("curl", &["-fsI", "--max-time", "5", &url]); - if probe.is_none() { + // `curl` is present on the supported operator hosts. `-D -` lets us + // validate headers and body from one bounded request. + let probe = run_probe("curl", &["-fsS", "--max-time", "5", "-D", "-", &url]); + if !probe.as_deref().is_some_and(aasa_response_is_valid) { failures.push(domain.to_string()); } } @@ -279,7 +350,7 @@ fn check_associated_domains() -> Option { "associated-domains", CheckStatus::Ok, format!( - "AASA files reachable for {} configured domain(s).", + "AASA files valid for {} configured domain(s).", domains.len() ), ) @@ -291,12 +362,12 @@ fn check_associated_domains() -> Option { "associated-domains", CheckStatus::Fail, format!( - "AASA file unreachable for: {}", + "AASA file invalid or unreachable for: {}", failures.join(", ") ), ) .with_remediation( - "Each domain must serve application/json at /.well-known/apple-app-site-association without redirects. See docs/DOMAIN_EXPANSION.md.", + "Each domain must serve application/json at /.well-known/apple-app-site-association without redirects and include webcredentials.apps. See docs/DOMAIN_EXPANSION.md.", ), ) } @@ -347,6 +418,48 @@ pub fn checks_to_json(checks: &[DoctorCheck]) -> Value { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; + use std::fs; + use std::path::Path; + use tempfile::TempDir; + + fn with_temp_home(run: F) + where + F: FnOnce(&Path), + { + let temp = TempDir::new().expect("failed to create temp home"); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", temp.path()); + + run(temp.path()); + + if let Some(previous_home) = previous_home { + std::env::set_var("HOME", previous_home); + } else { + std::env::remove_var("HOME"); + } + } + + fn write_supported_domains_config(home: &Path, domains: &[&str]) { + let config = json!({ + "schema": 1, + "port": 10000, + "host": "127.0.0.1", + "username": "", + "sharedKey": "", + "runtimeMode": "auto", + "secretSource": "file", + "supportedDomains": domains, + "createdAt": "2026-05-23T00:00:00Z" + }); + let apw_dir = home.join(".apw"); + fs::create_dir_all(&apw_dir).expect("failed to create .apw"); + fs::write( + apw_dir.join("config.json"), + serde_json::to_vec_pretty(&config).expect("failed to encode config"), + ) + .expect("failed to write config"); + } #[test] fn check_status_label_is_uppercase() { @@ -407,12 +520,34 @@ mod tests { } #[test] + #[serial] fn associated_domains_check_skipped_when_env_unset() { - std::env::remove_var("APW_AASA_DOMAINS"); - assert!(check_associated_domains().is_none()); + with_temp_home(|_| { + std::env::remove_var("APW_AASA_DOMAINS"); + assert!(check_associated_domains().is_none()); + }); + } + + #[test] + #[serial] + fn associated_domains_check_preserves_invalid_config_when_env_unset() { + with_temp_home(|home| { + std::env::remove_var("APW_AASA_DOMAINS"); + let apw_dir = home.join(".apw"); + fs::create_dir_all(&apw_dir).expect("failed to create .apw"); + let config_path = apw_dir.join("config.json"); + fs::write(&config_path, "{invalid").expect("failed to write invalid config"); + + assert!(check_associated_domains().is_none()); + assert!( + config_path.exists(), + "doctor must not clear malformed config" + ); + }); } #[test] + #[serial] fn associated_domains_check_reports_failure_for_unreachable_host() { // Use a guaranteed-unreachable .invalid TLD (RFC 2606). curl will // exit non-zero so the probe returns None and the check fails. @@ -420,6 +555,73 @@ mod tests { let check = check_associated_domains().expect("expected an AASA check"); std::env::remove_var("APW_AASA_DOMAINS"); assert_eq!(check.status, CheckStatus::Fail); - assert!(check.message.contains("unreachable")); + assert!(check.message.contains("invalid or unreachable")); + } + + #[test] + #[serial] + fn associated_domains_check_reads_supported_domains_config() { + with_temp_home(|home| { + std::env::remove_var("APW_AASA_DOMAINS"); + write_supported_domains_config(home, &["definitely-not-a-real-host.invalid"]); + + let check = check_associated_domains().expect("expected an AASA check"); + + assert_eq!(check.status, CheckStatus::Fail); + assert!(check.message.contains("definitely-not-a-real-host.invalid")); + assert!(check + .remediation + .unwrap_or_default() + .contains("DOMAIN_EXPANSION")); + }); + } + + #[test] + fn aasa_response_validation_accepts_json_webcredentials() { + let response = concat!( + "HTTP/2 200\r\n", + "content-type: application/json; charset=utf-8\r\n", + "\r\n", + r#"{"webcredentials":{"apps":["TEAMID.dev.omt.apw"]}}"# + ); + + assert!(aasa_response_is_valid(response)); + } + + #[test] + fn aasa_response_validation_rejects_redirects() { + let response = concat!( + "HTTP/2 302\r\n", + "location: https://example.com/aasa.json\r\n", + "content-type: application/json\r\n", + "\r\n", + r#"{"webcredentials":{"apps":["TEAMID.dev.omt.apw"]}}"# + ); + + assert!(!aasa_response_is_valid(response)); + } + + #[test] + fn aasa_response_validation_rejects_non_json_content_type() { + let response = concat!( + "HTTP/2 200\r\n", + "content-type: text/html\r\n", + "\r\n", + r#"{"webcredentials":{"apps":["TEAMID.dev.omt.apw"]}}"# + ); + + assert!(!aasa_response_is_valid(response)); + } + + #[test] + fn aasa_response_validation_rejects_missing_webcredentials_apps() { + let response = concat!( + "HTTP/2 200\r\n", + "content-type: application/json\r\n", + "\r\n", + r#"{"applinks":{"apps":[]}}"# + ); + + assert!(!aasa_response_is_valid(response)); } } diff --git a/rust/src/types.rs b/rust/src/types.rs index 77bd9e2..f549550 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -170,6 +170,13 @@ pub struct APWConfigV1 { pub bridge_last_error: Option, #[serde(rename = "secretSource", default)] pub secret_source: Option, + #[serde( + rename = "supportedDomains", + alias = "supported_domains", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub supported_domains: Vec, #[serde(rename = "fallbackProvider", alias = "fallback_provider", default)] pub fallback_provider: Option, #[serde( @@ -214,6 +221,7 @@ impl Default for APWConfigV1 { bridge_connected_at: None, bridge_last_error: None, secret_source: Some(SecretSource::File), + supported_domains: Vec::new(), fallback_provider: None, fallback_provider_path: None, fallback_provider_timeout_ms: None, diff --git a/rust/src/utils.rs b/rust/src/utils.rs index f4327f8..6464b24 100644 --- a/rust/src/utils.rs +++ b/rust/src/utils.rs @@ -232,6 +232,7 @@ fn normalize_legacy_config(raw: APWConfig) -> APWConfigV1 { } else { None }, + supported_domains: Vec::new(), fallback_provider: None, fallback_provider_path: None, fallback_provider_timeout_ms: None, @@ -251,6 +252,24 @@ pub fn read_config_file() -> Result { read_config_file_or_null() } +fn read_user_supported_domains_non_destructive() -> Vec { + let Ok(content) = fs::read_to_string(config_path()) else { + return Vec::new(); + }; + let Ok(parsed) = serde_json::from_str::(&content) else { + return Vec::new(); + }; + serde_json::from_value::(parsed) + .ok() + .filter(|config| config.schema == CONFIG_SCHEMA) + .map(|config| config.supported_domains) + .unwrap_or_default() +} + +pub fn configured_supported_domains_non_destructive() -> Vec { + read_user_supported_domains_non_destructive() +} + pub fn validate_external_provider_path( provider: ExternalFallbackProvider, provider_path: &str, @@ -345,6 +364,7 @@ pub fn read_config_file_or_empty() -> APWConfigV1 { bridge_connected_at: None, bridge_last_error: None, secret_source: Some(SecretSource::File), + supported_domains: Vec::new(), fallback_provider: None, fallback_provider_path: None, fallback_provider_timeout_ms: None, @@ -728,6 +748,10 @@ pub fn write_config(input: WriteConfigInput) -> Result { bridge_connected_at, bridge_last_error, created_at, + supported_domains: existing + .as_ref() + .map(|value| value.supported_domains.clone()) + .unwrap_or_default(), fallback_provider: existing.as_ref().and_then(|value| value.fallback_provider), fallback_provider_path: existing .as_ref() @@ -970,6 +994,20 @@ mod tests { }); } + #[test] + #[serial] + fn supported_domain_probe_read_preserves_invalid_user_config() { + with_temp_home(|| { + fs::create_dir_all(config_root()).unwrap(); + fs::write(config_path_for_test(), "{invalid").unwrap(); + + let domains = configured_supported_domains_non_destructive(); + + assert!(domains.is_empty()); + assert!(config_path_for_test().exists()); + }); + } + #[test] #[serial] fn read_config_rejects_oversized_payload() { @@ -1020,6 +1058,7 @@ mod tests { username: "alice".to_string(), shared_key: bigint_to_base64(&1u32.into()), secret_source: Some(SecretSource::File), + supported_domains: Vec::new(), fallback_provider: None, fallback_provider_path: None, fallback_provider_timeout_ms: None, @@ -1079,6 +1118,7 @@ mod tests { username: "alice".to_string(), shared_key: String::new(), secret_source: Some(SecretSource::Keychain), + supported_domains: Vec::new(), fallback_provider: None, fallback_provider_path: None, fallback_provider_timeout_ms: None, @@ -1155,6 +1195,7 @@ mod tests { username: "alice".to_string(), shared_key: String::new(), secret_source: Some(SecretSource::File), + supported_domains: Vec::new(), fallback_provider: None, fallback_provider_path: None, fallback_provider_timeout_ms: None, diff --git a/rust/tests/security_regressions.rs b/rust/tests/security_regressions.rs index eba9f64..3738838 100644 --- a/rust/tests/security_regressions.rs +++ b/rust/tests/security_regressions.rs @@ -99,6 +99,27 @@ fn write_fallback_provider_config(home: &Path, provider_path: &str) { .expect("failed to write config"); } +fn write_supported_domains_config(home: &Path, domains: &[&str]) { + let config = serde_json::json!({ + "schema": 1, + "port": 10_000, + "host": "127.0.0.1", + "username": "", + "sharedKey": "", + "runtimeMode": "auto", + "secretSource": "file", + "supportedDomains": domains, + "createdAt": Utc::now().to_rfc3339(), + }); + + fs::create_dir_all(home.join(".apw")).expect("failed to create config directory"); + fs::write( + home.join(".apw/config.json"), + serde_json::to_vec_pretty(&config).expect("failed to serialize config"), + ) + .expect("failed to write config"); +} + fn write_bitwarden_provider(path: &Path) { fs::write( path, @@ -188,6 +209,33 @@ fn threat_model_documents_current_v2_security_boundary() { ); } +#[test] +#[serial] +fn doctor_ci_reports_unreachable_supported_domain_from_config() { + with_temp_home(|home| { + env::remove_var("APW_AASA_DOMAINS"); + write_supported_domains_config(home, &["definitely-not-a-real-host.invalid"]); + + let (status, stdout, stderr) = run_command(home, &["doctor", "--ci"]); + + assert_eq!( + status, 0, + "status={status}, stdout={stdout}, stderr={stderr}" + ); + let output = parse_json_output(&stdout); + let checks = output["payload"].as_array().expect("expected checks array"); + let associated_domains = checks + .iter() + .find(|check| check["name"] == "associated-domains") + .expect("expected associated-domains check"); + assert_eq!(associated_domains["status"], "fail"); + assert!(associated_domains["message"] + .as_str() + .unwrap_or_default() + .contains("definitely-not-a-real-host.invalid")); + }); +} + #[test] #[serial] fn command_invalid_pin_is_rejected_without_network() {