diff --git a/src/asset_util/src/lib.rs b/src/asset_util/src/lib.rs index 217a346919..d8f1ece24f 100644 --- a/src/asset_util/src/lib.rs +++ b/src/asset_util/src/lib.rs @@ -387,6 +387,7 @@ lazy_static! { let mut map = HashMap::new(); map.insert(Path::new(".well-known/ic-domains").to_owned(), (ContentType::JSON, ContentEncoding::Identity)); map.insert(Path::new(".well-known/ii-alternative-origins").to_owned(), (ContentType::JSON, ContentEncoding::Identity)); + map.insert(Path::new(".well-known/webauthn").to_owned(), (ContentType::JSON, ContentEncoding::Identity)); map }; } @@ -645,6 +646,11 @@ fn should_return_correct_extension() { ContentType::JSON, ContentEncoding::Identity, ), + ( + ".well-known/webauthn", + ContentType::JSON, + ContentEncoding::Identity, + ), ( ".well-known/ii-alternative-origins", ContentType::JSON, diff --git a/src/internet_identity/src/assets.rs b/src/internet_identity/src/assets.rs index 1da5590829..386e706cf7 100644 --- a/src/internet_identity/src/assets.rs +++ b/src/internet_identity/src/assets.rs @@ -8,12 +8,13 @@ use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use ic_cdk::api; use include_dir::{include_dir, Dir}; +use serde_json::json; use sha2::Digest; // used both in init and post_upgrade -pub fn init_assets() { +pub fn init_assets(maybe_related_origins: Option>) { state::assets_mut(|certified_assets| { - let assets = get_static_assets(); + let assets = get_static_assets(maybe_related_origins); // Extract integrity hashes for all inlined scripts, from all the HTML files. let integrity_hashes = assets @@ -47,7 +48,7 @@ fn fixup_html(html: &str) -> String { static ASSET_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../dist"); // Gets the static assets. All static assets are prepared only once (like injecting the canister ID). -pub fn get_static_assets() -> Vec { +pub fn get_static_assets(maybe_related_origins: Option>) -> Vec { let mut assets = collect_assets(&ASSET_DIR, Some(fixup_html)); // Required to make II available on the identity.internetcomputer.org domain. @@ -58,6 +59,22 @@ pub fn get_static_assets() -> Vec { encoding: ContentEncoding::Identity, content_type: ContentType::OCTETSTREAM, }); + + if let Some(related_origins) = maybe_related_origins { + // Required to share passkeys with the different domains. Maximum of 5 labels. + // See https://web.dev/articles/webauthn-related-origin-requests#step_2_set_up_your_well-knownwebauthn_json_file_in_site-1 + let content = json!({ + "origins": related_origins, + }) + .to_string() + .into_bytes(); + assets.push(Asset { + url_path: "/.well-known/webauthn".to_string(), + content, + encoding: ContentEncoding::Identity, + content_type: ContentType::JSON, + }); + } assets } diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 9f525488a5..67895ff959 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -386,7 +386,12 @@ fn post_upgrade(maybe_arg: Option) { } fn initialize(maybe_arg: Option) { - init_assets(); + let state_related_origins = state::persistent_state(|storage| storage.related_origins.clone()); + let related_origins = maybe_arg + .clone() + .map(|arg| arg.related_origins) + .unwrap_or(state_related_origins); + init_assets(related_origins); apply_install_arg(maybe_arg); update_root_hash(); } diff --git a/src/internet_identity/tests/integration/http.rs b/src/internet_identity/tests/integration/http.rs index 32a5b7c611..d6493678ad 100644 --- a/src/internet_identity/tests/integration/http.rs +++ b/src/internet_identity/tests/integration/http.rs @@ -20,6 +20,7 @@ use internet_identity_interface::internet_identity::types::{ }; use pocket_ic::{CallError, PocketIc}; use serde_bytes::ByteBuf; +use serde_json::json; use std::collections::HashMap; use std::io::Read; use std::time::Duration; @@ -76,6 +77,134 @@ fn ii_canister_serves_http_assets() -> Result<(), CallError> { Ok(()) } +/// Verifies that `.well-known/webauthn` assets are delivered, certified and have security headers if present in the config. +#[test] +fn ii_canister_serves_webauthn_assets() -> Result<(), CallError> { + let env = env(); + let related_origins: Vec = [ + "https://identity.internetcomputer.org".to_string(), + "https://identity.ic0.app".to_string(), + ] + .to_vec(); + let config = InternetIdentityInit { + assigned_user_number_range: None, + archive_config: None, + canister_creation_cycles_cost: None, + register_rate_limit: None, + captcha_config: None, + related_origins: Some(related_origins.clone()), + }; + let canister_id = install_ii_canister_with_arg(&env, II_WASM.clone(), Some(config)); + + for certification_version in 1..=2 { + let request = HttpRequest { + method: "GET".to_string(), + url: "/.well-known/webauthn".to_string(), + headers: vec![], + body: ByteBuf::new(), + certificate_version: Some(certification_version), + }; + let http_response = http_request(&env, canister_id, &request)?; + let response_body = String::from_utf8_lossy(&http_response.body).to_string(); + + assert_eq!(http_response.status_code, 200); + + let expected_content = json!({ + "origins": related_origins, + }) + .to_string(); + assert_eq!(response_body, expected_content); + + // check the appropriate Content-Type header is set + let (_, content_type) = http_response + .headers + .iter() + .find(|(name, _)| name.to_lowercase() == "content-type") + .expect("Content-Encoding header not found"); + assert_eq!( + content_type, "application/json", + "unexpected Content-Encoding header value" + ); + verify_security_headers(&http_response.headers); + + let result = verify_response_certification( + &env, + canister_id, + request, + http_response, + certification_version, + ); + assert_eq!(result.verification_version, certification_version); + } + Ok(()) +} + +#[test] +fn ii_canister_serves_webauthn_assets_after_upgrade() -> Result<(), CallError> { + let env = env(); + let related_origins: Vec = [ + "https://identity.internetcomputer.org".to_string(), + "https://identity.ic0.app".to_string(), + ] + .to_vec(); + let config = InternetIdentityInit { + assigned_user_number_range: None, + archive_config: None, + canister_creation_cycles_cost: None, + register_rate_limit: None, + captcha_config: None, + related_origins: Some(related_origins.clone()), + }; + let canister_id = install_ii_canister_with_arg(&env, II_WASM.clone(), Some(config)); + + let request = HttpRequest { + method: "GET".to_string(), + url: "/.well-known/webauthn".to_string(), + headers: vec![], + body: ByteBuf::new(), + certificate_version: Some(2), + }; + let http_response = http_request(&env, canister_id, &request)?; + let response_body = String::from_utf8_lossy(&http_response.body).to_string(); + assert_eq!(http_response.status_code, 200); + let expected_content = json!({ + "origins": related_origins, + }) + .to_string(); + assert_eq!(response_body, expected_content); + + let _ = upgrade_ii_canister_with_arg(&env, canister_id, II_WASM.clone(), None); + + let http_response_1 = http_request(&env, canister_id, &request)?; + let response_body_1 = String::from_utf8_lossy(&http_response_1.body).to_string(); + assert_eq!(response_body_1, expected_content); + + let related_origins_2: Vec = [ + "https://beta.identity.internetcomputer.org".to_string(), + "https://beta.identity.ic0.app".to_string(), + ] + .to_vec(); + let config_2 = InternetIdentityInit { + assigned_user_number_range: None, + archive_config: None, + canister_creation_cycles_cost: None, + register_rate_limit: None, + captcha_config: None, + related_origins: Some(related_origins_2.clone()), + }; + + let _ = upgrade_ii_canister_with_arg(&env, canister_id, II_WASM.clone(), Some(config_2)); + + let http_response_2 = http_request(&env, canister_id, &request)?; + let response_body_2 = String::from_utf8_lossy(&http_response_2.body).to_string(); + let expected_content_2 = json!({ + "origins": related_origins_2, + }) + .to_string(); + assert_eq!(response_body_2, expected_content_2); + Ok(()) +} + /// Verifies that clients that do not indicate any certification version will get a v1 certificate. #[test] fn should_fallback_to_v1_certification() -> Result<(), CallError> {