diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9de03e36..d20471752 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,7 @@ jobs: - name: Run tests env: FDO_PRIVILEGED: true + PER_DEVICE_SERVICEINFO: false run: cargo test - name: Check aio run: | diff --git a/Cargo.lock b/Cargo.lock index 447828ee1..7d59869cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2667,9 +2667,9 @@ dependencies = [ [[package]] name = "serde_yaml" -version = "0.9.19" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f82e6c8c047aa50a7328632d067bcae6ef38772a79e28daf32f735e0e4f3dd10" +checksum = "d9d684e3ec7de3bf5466b32bd75303ac16f0736426e5a4e0d6e489559ce1249c" dependencies = [ "indexmap", "itoa", diff --git a/HOWTO.md b/HOWTO.md index 61aa20c40..dd9dc5840 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -22,6 +22,8 @@ - How to run the clients: - Linuxapp client - Manufacturing client +- How to use Features: + - How to use the `per-device serviceinfo` feature ## Pre-requisites @@ -734,3 +736,26 @@ Options: Please note that in this mode there are some environment variables that are still required to be set by the user (`DI_SIGN_KEY_PATH`, `DI_HMAC_KEY_PATH`). + +## How to use Features + +### How to use the `per-device serviceinfo` feature + + Using this feature the user can choose to apply different serviceinfo settings on different devices. + For that the user needs to provide a path to a `per-device serviceinfo` file under the `device_specific_store_driver` field + present in the `serviceinfo_api_server.yml` file. + If other devices do not have their `per-device serviceinfo` file under `device_specific_store_driver` they will get onboarded + with settings from the main file, which is `serviceinfo_api_server.yml`. + + 1. Initialize the device as mentioned in [How to generate an Ownership Voucher and Credential for a Device](#how-to-generate-an-ownership-voucher-ov-and-credential-for-a-device-device-initialization). + + 2. Dump the `device-credentials` + ```bash + fdo-owner-tool dump-device-credential /path/to/device-credentials + ``` + + 3. Note the GUID of the device and create a .yml file with same name as the `guid` under directory path `device_specific_store_driver`. + + 4. You can refer to [per_device_serviceinfo.yml](https://github.com/fedora-iot/fido-device-onboard-rs/blob/main/examples/config/device_specific_serviceinfo.yml) as an example. + + 5. Follow the onboarding procedure and this particular device will get the serviceinfo settings as mentioned in the above file. \ No newline at end of file diff --git a/examples/config/device_specific_serviceinfo.yml b/examples/config/device_specific_serviceinfo.yml new file mode 100755 index 000000000..d6de355d5 --- /dev/null +++ b/examples/config/device_specific_serviceinfo.yml @@ -0,0 +1,9 @@ +initial_user: + username: username_per_device + sshkeys: + - "testkeyperdevice" +files: null +commands: null +diskencryption_clevis: null +additional_serviceinfo: null +after_onboarding_reboot: false diff --git a/integration-tests/templates/serviceinfo-api-server.yml.j2 b/integration-tests/templates/serviceinfo-api-server.yml.j2 index 6fd15410a..95b58ef9b 100644 --- a/integration-tests/templates/serviceinfo-api-server.yml.j2 +++ b/integration-tests/templates/serviceinfo-api-server.yml.j2 @@ -9,7 +9,7 @@ service_info: initial_user: username: {{ user }} sshkeys: - - "testkey" + - {{ sshkey }} files: - path: /etc/hosts permissions: 644 diff --git a/integration-tests/tests/common/mod.rs b/integration-tests/tests/common/mod.rs index d84adf355..2b683dee5 100644 --- a/integration-tests/tests/common/mod.rs +++ b/integration-tests/tests/common/mod.rs @@ -778,6 +778,16 @@ impl<'a> TestServerConfigurator<'a> { .runner_path(&self.server_number) .join(config_file_name); + let per_device: bool = match env::var("PER_DEVICE_SERVICEINFO") { + Ok(val) => val.parse().unwrap_or(false), + Err(e) => { + eprintln!( + "Error reading environment variable: {} setting to default", + e + ); + false + } + }; self.test_context .generate_config_file(&output_path, config_file_name, |cfg| { cfg.insert( @@ -790,10 +800,23 @@ impl<'a> TestServerConfigurator<'a> { "config_dir", &self.test_context.runner_path(&self.server_number), ); - cfg.insert( - "user", - users::get_current_username().unwrap().to_str().unwrap(), - ); + + if !per_device { + L.l("per_device_serviceinfo is not set, using default values"); + cfg.insert( + "user", + users::get_current_username().unwrap().to_str().unwrap(), + ); + cfg.insert("sshkey", "sshkey_default"); + } else { + L.l("per_device_serviceinfo is set, using device specific values"); + cfg.insert( + "user", + users::get_current_username().unwrap().to_str().unwrap(), + ); + cfg.insert("sshkey", "sshkey_per_device"); + } + // TODO: Insert more defaults context_configurator(cfg) diff --git a/integration-tests/tests/e2e.rs b/integration-tests/tests/e2e.rs index 2da425791..02c9701e1 100644 --- a/integration-tests/tests/e2e.rs +++ b/integration-tests/tests/e2e.rs @@ -63,11 +63,38 @@ async fn test_e2e() -> Result<()> { let mut failed = Vec::new(); + env::set_var("PER_DEVICE_SERVICEINFO", "false"); + for (diun_verification_method_name, diun_verification_method_generator) in &diun_verification_methods[1..2] { + // for default serviceinfo + for diun_key_type in &diun_key_types { + match test_e2e_impl_default_serviceinfo( + diun_verification_method_generator, + diun_key_type, + ) + .await + { + Ok(()) => (), + Err(e) => { + failed.push(TestCase { + diun_verification_method_name: diun_verification_method_name, + diun_key_type: diun_key_type, + error: e, + }); + } + } + } + + // for per_device serviceinfo for diun_key_type in &diun_key_types { - match test_e2e_impl(diun_verification_method_generator, diun_key_type).await { + match test_e2e_impl_per_device_serviceinfo( + diun_verification_method_generator, + diun_key_type, + ) + .await + { Ok(()) => (), Err(e) => { failed.push(TestCase { @@ -90,15 +117,6 @@ async fn test_e2e() -> Result<()> { } } -#[derive(Debug, serde::Serialize)] -struct ServiceInfoApiAdminV0Request { - device_guid: String, - // This is not entirely accurate (it accepts any JSON value), but for this test sufficient - service_info: Vec<(String, String, serde_json::Value)>, -} - -const CI_TESTSTRING: &str = "CI_TESTSTRING_SHOULD_BE_RETURNED_IN_SERVICEINFO"; - #[derive(Debug)] struct TestCase { #[allow(dead_code)] @@ -109,13 +127,17 @@ struct TestCase { error: anyhow::Error, } -async fn test_e2e_impl(verification_generator: F, diun_key_type: &str) -> Result<()> +async fn test_e2e_impl_default_serviceinfo( + verification_generator: F, + diun_key_type: &str, +) -> Result<()> where F: Fn(&TestContext) -> Result<(&'static str, String, &'static str)>, { let ci = env::var("FDO_PRIVILEGED").is_ok(); + env::set_var("PER_DEVICE_SERVICEINFO", "false"); let mut ctx = TestContext::new().context("Error building test context")?; - let new_user: &str = "testuser"; //new user to be created during onboarding + let new_user: &str = "testuser"; // new user to be created during onboarding let encrypted_disk_loc = ctx.testpath().join("encrypted.img"); let rendezvous_server = ctx .start_test_server( @@ -317,35 +339,12 @@ where bail!("Failed to call clevis luks bind"); } - #[allow(unused_mut)] - let mut service_info = vec![( - "CI".to_string(), - "teststring".to_string(), - serde_json::Value::String(CI_TESTSTRING.to_string()), - )]; - let client = reqwest::Client::new(); - // Submit additional ServiceInfo for this device - client - .post(format!( - "http://localhost:{}/admin/v0", //DevSkim: ignore DS137138 - serviceinfo_api_server.server_port().unwrap() - )) - .header("Authorization", "Bearer TestAdminToken") - .json(&ServiceInfoApiAdminV0Request { - device_guid: device_guid, - service_info: service_info, - }) - .send() - .await - .context("Error sending ServiceInfo API request")? - .error_for_status() - .context("Error from the ServiceInfo API request")?; // Ensure TO0 is executed let res = client .post(format!( - "http://localhost:{}/report-to-rendezvous", //DevSkim: ignore DS137138 + "http://localhost:{}/report-to-rendezvous", // DevSkim: ignore DS137138 owner_onboarding_server.server_port().unwrap() )) .send() @@ -381,7 +380,6 @@ where ) .context("Error running client")?; output.expect_success().context("client failed")?; - output.expect_stderr_line(CI_TESTSTRING)?; pretty_assertions::assert_eq!( fs::read_to_string(&marker_file_path).context("Error reading marker file")?, @@ -392,7 +390,7 @@ where .context("Error reading authorized SSH keys")?, " # These keys are installed by FIDO Device Onboarding -testkey +sshkey_default # End of FIDO Device Onboarding keys " ); @@ -442,3 +440,240 @@ testkey Ok(()) } + +async fn test_e2e_impl_per_device_serviceinfo( + verification_generator: F, + diun_key_type: &str, +) -> Result<()> +where + F: Fn(&TestContext) -> Result<(&'static str, String, &'static str)>, +{ + env::set_var("PER_DEVICE_SERVICEINFO", "true"); + let mut ctx = TestContext::new().context("Error building test context")?; + let encrypted_disk_loc = ctx.testpath().join("encrypted2.img"); + let rendezvous_server = ctx + .start_test_server( + Binary::RendezvousServer, + |cfg| Ok(cfg.prepare_config_file(None, |_| Ok(()))?), + |_| Ok(()), + ) + .context("Error creating rendezvous server")?; + let serviceinfo_api_server = ctx + .start_test_server( + Binary::ServiceInfoApiServer, + |cfg| { + Ok(cfg.prepare_config_file(None, |cfg| { + cfg.insert( + "encrypted_disk_label", + &encrypted_disk_loc.to_string_lossy(), + ); + Ok(()) + })?) + }, + |_| Ok(()), + ) + .context("Error creating serviceinfo API dev server")?; + let owner_onboarding_server = ctx + .start_test_server( + Binary::OwnerOnboardingServer, + |cfg| { + Ok(cfg.prepare_config_file(None, |cfg| { + cfg.insert( + "serviceinfo_api_server_port", + &serviceinfo_api_server.server_port().unwrap(), + ); + Ok(()) + })?) + }, + |cmd| { + cmd.env("ALLOW_NONINTEROPERABLE_KDF", &"1"); + Ok(()) + }, + ) + .context("Error creating owner server")?; + let mfg_server = ctx + .start_test_server( + Binary::ManufacturingServer, + |cfg| { + Ok(cfg.prepare_config_file(None, |cfg| { + cfg.insert("diun_key_type", diun_key_type); + cfg.insert("rendezvous_port", &rendezvous_server.server_port().unwrap()); + cfg.insert("device_identification_format", "SerialNumber"); + Ok(()) + })?) + }, + |_| Ok(()), + ) + .context("Error creating manufacturing server")?; + ctx.wait_until_servers_ready() + .await + .context("Error waiting for servers to start")?; + + let (verification_key, verification_value, verification_searchstr) = + verification_generator(&ctx).context("Error generating verification information")?; + + // Execute the DI(UN) protocols + let client_result = ctx + .run_client( + Binary::ManufacturingClient, + Some(&mfg_server), + |cfg| { + cfg.env("DEVICE_CREDENTIAL_FILENAME", "devicecredential.dc") + .env("MANUFACTURING_INFO", "testdevice") + .env(&verification_key, &verification_value); + Ok(()) + }, + Duration::from_secs(5), + ) + .context("Error running manufacturing client")?; + client_result + .expect_success() + .context("Manufacturing client failed")?; + client_result.expect_stderr_line(verification_searchstr)?; + + let dc_path = client_result.client_path().join("devicecredential.dc"); + let client = reqwest::Client::new(); + + L.l("Adding disk encryption tests"); + L.l("Creating empty disk image"); + if !Command::new("truncate") + .arg("-s") + .arg("1G") + .arg(&encrypted_disk_loc) + .status() + .context("Error running truncate")? + .success() + { + bail!("Error creating empty disk image"); + } + + L.l("Encrypting disk image"); + let mut child = Command::new("cryptsetup") + .arg("luksFormat") + .arg(&encrypted_disk_loc) + .arg("--force-password") + .stdin(std::process::Stdio::piped()) + .spawn() + .context("Error starting cryptsetup luksFormat")?; + { + let mut stdin = child.stdin.take().context("Error taking stdin")?; + writeln!(stdin, "testpassword")?; + stdin.flush()?; + } + + let output = child.wait().context("Error waiting for cryptsetup")?; + if !output.success() { + bail!("Failed to call cryptsetup"); + } + + L.l("Adding disk encryption tests"); + L.l("Creating empty disk image"); + if !Command::new("truncate") + .arg("-s") + .arg("1G") + .arg(&encrypted_disk_loc) + .status() + .context("Error running truncate")? + .success() + { + bail!("Error creating empty disk image"); + } + + L.l("Encrypting disk image"); + let mut child = Command::new("cryptsetup") + .arg("luksFormat") + .arg(&encrypted_disk_loc) + .arg("--force-password") + .stdin(std::process::Stdio::piped()) + .spawn() + .context("Error starting cryptsetup luksFormat")?; + { + let mut stdin = child.stdin.take().context("Error taking stdin")?; + writeln!(stdin, "testpassword")?; + stdin.flush()?; + } + + let output = child.wait().context("Error waiting for cryptsetup")?; + if !output.success() { + bail!("Failed to call cryptsetup"); + } + L.l("Binding disk image"); + let mut child = Command::new("clevis") + .arg("luks") + .arg("bind") + .arg("-d") + .arg(&encrypted_disk_loc) + .arg("test") + .arg("{}") + .env("PATH", ctx.get_path_env()?) + .stdin(std::process::Stdio::piped()) + .spawn() + .context("Error starting clevis luks bind")?; + { + let mut stdin = child.stdin.take().context("Error taking stdin")?; + writeln!(stdin, "testpassword")?; + stdin.flush()?; + } + + let output = child.wait().context("Error waiting for clevis to bind")?; + if !output.success() { + bail!("Failed to call clevis luks bind"); + } + + // Ensure TO0 is executed + let res = client + .post(format!( + "http://localhost:{}/report-to-rendezvous", // DevSkim: ignore DS137138 + owner_onboarding_server.server_port().unwrap() + )) + .send() + .await?; + L.l(format!("Status code report-to-rendezvous {}", res.status())); + + // Execute TO1/TO2 protocols + let ssh_authorized_keys_path = ctx.testpath().join("authorized_keys"); + let marker_file_path = ctx.testpath().join("marker"); + let binary_file_path_prefix = ctx.testpath().join("binary_files"); + + std::fs::create_dir(&binary_file_path_prefix).context("Error creating binary_files dir")?; + + let output = ctx + .run_client( + Binary::ClientLinuxapp, + None, + |cfg| { + cfg.env("DEVICE_CREDENTIAL", dc_path.to_str().unwrap()) + .env("SSH_KEY_PATH", &ssh_authorized_keys_path.to_str().unwrap()) + .env( + "BINARYFILE_PATH_PREFIX", + binary_file_path_prefix.to_str().unwrap(), + ) + .env( + "DEVICE_ONBOARDING_EXECUTED_MARKER_FILE_PATH", + &marker_file_path.to_str().unwrap(), + ) + .env("ALLOW_NONINTEROPERABLE_KDF", &"1"); + Ok(()) + }, + Duration::from_secs(60), + ) + .context("Error running client")?; + output.expect_success().context("client failed")?; + + pretty_assertions::assert_eq!( + fs::read_to_string(&marker_file_path).context("Error reading marker file")?, + "executed" + ); + pretty_assertions::assert_eq!( + fs::read_to_string(&ssh_authorized_keys_path) + .context("Error reading authorized per device SSH keys")?, + " +# These keys are installed by FIDO Device Onboarding +sshkey_per_device +# End of FIDO Device Onboarding keys +" + ); + + env::set_var("PER_DEVICE_SERVICEINFO", "false"); + Ok(()) +} diff --git a/integration-tests/tests/to.rs b/integration-tests/tests/to.rs index 9d823a781..34c66a7c2 100644 --- a/integration-tests/tests/to.rs +++ b/integration-tests/tests/to.rs @@ -263,7 +263,7 @@ async fn test_to_impl( .context("Error reading authorized SSH keys")?, " # These keys are installed by FIDO Device Onboarding -testkey +sshkey_default # End of FIDO Device Onboarding keys " ); diff --git a/serviceinfo-api-server/src/main.rs b/serviceinfo-api-server/src/main.rs index 46fd3521c..38bb9e939 100644 --- a/serviceinfo-api-server/src/main.rs +++ b/serviceinfo-api-server/src/main.rs @@ -1,10 +1,4 @@ -use std::{collections::HashSet, str::FromStr}; - use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use tokio::signal::unix::{signal, SignalKind}; -use warp::Filter; - use fdo_data_formats::{ constants::{FedoraIotServiceInfoModule, HashType, ServiceInfoModule}, types::{Guid, Hash}, @@ -12,8 +6,17 @@ use fdo_data_formats::{ use fdo_store::Store; use fdo_util::servers::{ configuration::serviceinfo_api_server::{ServiceInfoApiServerSettings, ServiceInfoSettings}, - settings_for, ServiceInfoApiReply, ServiceInfoApiReplyInitialUser, ServiceInfoApiReplyReboot, + settings_for, settings_per_device, ServiceInfoApiReply, ServiceInfoApiReplyInitialUser, + ServiceInfoApiReplyReboot, }; +use serde::{Deserialize, Serialize}; +use std::{collections::HashSet, str::FromStr}; +use tokio::signal::unix::{signal, SignalKind}; +use warp::Filter; + +#[derive(Debug)] +struct ServiceInfoFailure(anyhow::Error); +impl warp::reject::Reject for ServiceInfoFailure {} #[derive(Debug)] struct ServiceInfoConfiguration { @@ -224,12 +227,30 @@ async fn serviceinfo_handler( .modules .contains(&FedoraIotServiceInfoModule::SSHKey.into()) { - if let Some(initial_user) = &user_data.service_info_configuration.settings.initial_user { - reply.reply.initial_user = Some(ServiceInfoApiReplyInitialUser { - username: initial_user.username.clone(), - ssh_keys: initial_user.sshkeys.clone(), - }); - } + // precedence is given to 'per_device' settings over base serviceinfo_api_server.yml config + match settings_per_device(&query_info.device_guid.to_string().replace('\"', "")) { + Ok(config) => { + let per_device_settings = config; + if let Some(initial_user) = &per_device_settings.initial_user { + reply.reply.initial_user = Some(ServiceInfoApiReplyInitialUser { + username: initial_user.username.clone(), + ssh_keys: initial_user.sshkeys.clone(), + }); + } + } + Err(_) => { + log::info!("per-device settings file not available, so loading base config file"); + if let Some(initial_user) = + &user_data.service_info_configuration.settings.initial_user + { + log::debug!("serviceinfo setting from base file applied"); + reply.reply.initial_user = Some(ServiceInfoApiReplyInitialUser { + username: initial_user.username.clone(), + ssh_keys: initial_user.sshkeys.clone(), + }); + } + } + }; } if query_info @@ -364,25 +385,6 @@ async fn serviceinfo_handler( } } } - - let device_specific_info = match user_data - .device_specific_store - .load_data(&query_info.device_guid) - .await - { - Ok(res) => res, - Err(e) => { - log::warn!("Error loading device specific store: {:?}", e); - return Err(warp::reject::reject()); - } - }; - if let Some(device_specific_info) = device_specific_info { - log::trace!("Loaded device-specific information"); - for (module, key, value) in device_specific_info { - reply.add_extra(module, &key, &value); - } - } - Ok(warp::reply::json(&reply.reply)) } diff --git a/util/src/servers/configuration/serviceinfo_api_server.rs b/util/src/servers/configuration/serviceinfo_api_server.rs index adb38cc16..1b3e6c6cd 100644 --- a/util/src/servers/configuration/serviceinfo_api_server.rs +++ b/util/src/servers/configuration/serviceinfo_api_server.rs @@ -19,6 +19,7 @@ pub struct ServiceInfoApiServerSettings { } #[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] pub struct ServiceInfoSettings { pub initial_user: Option, diff --git a/util/src/servers/mod.rs b/util/src/servers/mod.rs index dc2fe2fe7..de566eabb 100644 --- a/util/src/servers/mod.rs +++ b/util/src/servers/mod.rs @@ -1,16 +1,19 @@ +use anyhow::{bail, Context, Result}; use config::Config; use fdo_data_formats::constants::ServiceInfoModule; +use fdo_store::StoreConfig; use glob::glob; use serde::{Deserialize, Serialize}; -use std::env; -use std::path::Path; - -use anyhow::{bail, Context, Result}; - use serde_cbor::Value as CborValue; use serde_yaml::Value; +use std::env; +use std::path::Path; +use std::result::Result::Ok; pub mod configuration; +use crate::servers::configuration::serviceinfo_api_server::{ + ServiceInfoApiServerSettings, ServiceInfoSettings, +}; // TODO(runcom): find a better home for this as it's shared between // owner-onboarding-server and manufacturing-server... @@ -57,6 +60,44 @@ pub fn settings_for(component: &str) -> Result { .context(format!("Loading configuration for {component}")) } +pub fn settings_per_device(guid: &str) -> Result { + // here we first check if the requested device has per-device file stored + // in device_specific_store_driver, if not return error + + let settings: ServiceInfoApiServerSettings = settings_for("serviceinfo-api-server")? + .try_deserialize() + .context("Error parsing configuration")?; + + let path_per_device_store = match settings.device_specific_store_driver { + StoreConfig::Directory { mut path } => { + let file_name = format!("{}.yml", guid); + path.push(file_name); + path.to_string_lossy().into_owned() + } + }; + let config = Config::builder() + .add_source(config::File::from(Path::new(&path_per_device_store))) + .build() + .context(format!( + "Error loading device specific config file for {path_per_device_store}" + ))?; + log::debug!("Loaded device specific config from {path_per_device_store}"); + let per_device_settings = config.try_deserialize::()?; + log::debug!( + "device specific serviceinfosettings: initial_user: {:#?} username: {:#?} sshkeys {:#?}", + per_device_settings.initial_user, + per_device_settings + .initial_user + .as_ref() + .map(|user| &user.username), + per_device_settings + .initial_user + .as_ref() + .map(|user| &user.sshkeys) + ); + Ok(per_device_settings) +} + pub fn format_conf_env(component: &str) -> String { format!("{}_CONF", component_env_prefix(component)) }