From d772adaed2e563b255333a150b2f32330582ee18 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 01:03:36 +0100 Subject: [PATCH 01/43] Cleanup deps --- Cargo.lock | 4 ---- libwebapi/Cargo.toml | 4 ---- 2 files changed, 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 67d734ac..bd467900 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4004,10 +4004,8 @@ dependencies = [ "actix-files", "actix-web", "async-trait", - "base64", "colored", "futures-util", - "hex", "hostname", "indexmap 2.13.0", "libcommon", @@ -4017,11 +4015,9 @@ dependencies = [ "once_cell", "pam", "reqwest 0.12.28", - "rsa", "serde", "serde_json", "serde_yaml", - "sodiumoxide", "tempfile", "tokio", "utoipa", diff --git a/libwebapi/Cargo.toml b/libwebapi/Cargo.toml index bc3f3338..6e937a98 100644 --- a/libwebapi/Cargo.toml +++ b/libwebapi/Cargo.toml @@ -5,11 +5,9 @@ edition = "2024" [dependencies] actix-web = "4.12.1" -hex = "0.4.3" reqwest = { version = "0.12.28", features = ["blocking", "json"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -sodiumoxide = "0.2.7" tokio = { version = "1.49.0", features = ["full"] } once_cell = "1.21.3" log = "0.4.29" @@ -25,8 +23,6 @@ utoipa-swagger-ui = { version = "9.0.2", features = [ utoipa = { version = "5.4.0", features = ["actix_extras"] } pam = "0.8.0" uuid = "1.19.0" -base64 = "0.22.1" -rsa = "0.9.10" colored = "3.1.1" serde_yaml = "0.9.34" indexmap = { version = "2.13.0", features = ["serde"] } From 8890e63d2717a9fe50bf7dcdf142aceb2479e6c9 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 01:25:33 +0100 Subject: [PATCH 02/43] Implement TLS config --- libsysinspect/src/cfg/mmconf.rs | 51 ++++++++++++++++++++++++++++++ libsysinspect/src/cfg/mmconf_ut.rs | 28 ++++++++++++++++ sysclient/src/lib.rs | 10 +++--- 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index f9018c9b..a3ef99d7 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -825,6 +825,26 @@ pub struct MasterConfig { #[serde(rename = "api.auth")] pam_enabled: Option, + /// Enable TLS for the embedded Web API listener. + #[serde(rename = "api.tls.enabled")] + api_tls_enabled: Option, + + /// Path to the PEM certificate chain used by the Web API TLS listener. + #[serde(rename = "api.tls.cert-file")] + api_tls_cert_file: Option, + + /// Path to the PEM private key used by the Web API TLS listener. + #[serde(rename = "api.tls.key-file")] + api_tls_key_file: Option, + + /// Optional CA bundle path used for TLS validation or mutual TLS extensions. + #[serde(rename = "api.tls.ca-file")] + api_tls_ca_file: Option, + + /// Allow explicitly trusting self-signed or non-standard TLS setups. + #[serde(rename = "api.tls.trust-self-signed")] + api_tls_trust_self_signed: Option, + /// Enable development-only Web API shortcuts. /// /// This keeps the normal Web API enabled, but allows the authentication @@ -996,6 +1016,31 @@ impl MasterConfig { self.api_version.unwrap_or(1) } + /// Return whether HTTPS/TLS is enabled for the embedded Web API listener. + pub fn api_tls_enabled(&self) -> bool { + self.api_tls_enabled.unwrap_or(false) + } + + /// Return the configured Web API TLS certificate path, resolved against the SysInspect root when relative. + pub fn api_tls_cert_file(&self) -> Option { + self.api_tls_cert_file.as_deref().map(|path| self.resolve_rooted_path(path)) + } + + /// Return the configured Web API TLS private key path, resolved against the SysInspect root when relative. + pub fn api_tls_key_file(&self) -> Option { + self.api_tls_key_file.as_deref().map(|path| self.resolve_rooted_path(path)) + } + + /// Return the optional Web API TLS CA bundle path, resolved against the SysInspect root when relative. + pub fn api_tls_ca_file(&self) -> Option { + self.api_tls_ca_file.as_deref().map(|path| self.resolve_rooted_path(path)) + } + + /// Return whether the Web API may explicitly trust self-signed TLS setups. + pub fn api_tls_trust_self_signed(&self) -> bool { + self.api_tls_trust_self_signed.unwrap_or(false) + } + /// Get API authentication method pub fn api_auth(&self) -> AuthMethod { match self.pam_enabled.as_deref().map(|s| s.to_ascii_lowercase()) { @@ -1065,6 +1110,12 @@ impl MasterConfig { PathBuf::from(DEFAULT_SYSINSPECT_ROOT.to_string()) } + /// Resolve a path under the SysInspect root unless it is already absolute. + fn resolve_rooted_path(&self, path: &str) -> PathBuf { + let path = PathBuf::from(path); + if path.is_absolute() { path } else { self.root_dir().join(path) } + } + /// Get minion keys store pub fn minion_keys_root(&self) -> PathBuf { self.root_dir().join(CFG_MINION_KEYS) diff --git a/libsysinspect/src/cfg/mmconf_ut.rs b/libsysinspect/src/cfg/mmconf_ut.rs index 5ac84f22..275c3df7 100644 --- a/libsysinspect/src/cfg/mmconf_ut.rs +++ b/libsysinspect/src/cfg/mmconf_ut.rs @@ -56,6 +56,34 @@ fn master_transport_paths_are_under_managed_transport_root() { assert_eq!(cfg.transport_minions_root(), cfg.transport_root().join(CFG_TRANSPORT_MINIONS)); } +#[test] +fn master_api_tls_relative_paths_are_resolved_under_root() { + let cfg = MasterConfig::new(write_master_cfg( + "config:\n master:\n fileserver.models: []\n api.tls.enabled: true\n api.tls.cert-file: etc/web/api.crt\n api.tls.key-file: etc/web/api.key\n api.tls.ca-file: trust/ca.pem\n api.tls.trust-self-signed: true\n", + )) + .unwrap(); + + assert!(cfg.api_tls_enabled()); + assert_eq!(cfg.api_tls_cert_file().unwrap(), cfg.root_dir().join("etc/web/api.crt")); + assert_eq!(cfg.api_tls_key_file().unwrap(), cfg.root_dir().join("etc/web/api.key")); + assert_eq!(cfg.api_tls_ca_file().unwrap(), cfg.root_dir().join("trust/ca.pem")); + assert!(cfg.api_tls_trust_self_signed()); +} + +#[test] +fn master_api_tls_absolute_paths_stay_absolute() { + let cfg = MasterConfig::new(write_master_cfg( + "config:\n master:\n fileserver.models: []\n api.tls.cert-file: /srv/tls/api.crt\n api.tls.key-file: /srv/tls/api.key\n api.tls.ca-file: /srv/tls/ca.pem\n", + )) + .unwrap(); + + assert_eq!(cfg.api_tls_cert_file().unwrap(), std::path::PathBuf::from("/srv/tls/api.crt")); + assert_eq!(cfg.api_tls_key_file().unwrap(), std::path::PathBuf::from("/srv/tls/api.key")); + assert_eq!(cfg.api_tls_ca_file().unwrap(), std::path::PathBuf::from("/srv/tls/ca.pem")); + assert!(!cfg.api_tls_enabled()); + assert!(!cfg.api_tls_trust_self_signed()); +} + #[test] fn minion_transport_paths_are_under_managed_transport_root() { let mut cfg = MinionConfig::default(); diff --git a/sysclient/src/lib.rs b/sysclient/src/lib.rs index a694a549..572d6044 100644 --- a/sysclient/src/lib.rs +++ b/sysclient/src/lib.rs @@ -78,9 +78,8 @@ pub struct ModelResponse { pub model: ModelInfo, } -/// SysClient is the main client for interacting with the SysInspect system. -/// It provides methods to set up RSA encryption, manage configurations, and interact with the system. -/// It handles user authentication, key management, and data encryption/decryption. +/// SysClient is the main client for interacting with the SysInspect Web API. +/// It handles authentication and plain JSON request/response flows. /// /// # Fields /// * `cfg` - The configuration for the SysClient, which includes the master URL. @@ -152,9 +151,8 @@ impl SysClient { /// * Returns `SysinspectError::MasterGeneralError` if the client is not authenticated (i.e., `sid` is empty), /// * Returns `SysinspectError::MasterGeneralError` if there is an error during the query process, such as network issues or server errors. /// - /// This function constructs a JSON payload containing the session ID and the query, - /// encodes it, and sends it to the SysInspect system using the `query_handler` API. - /// It expects the SysInspect system to respond with a string, which is returned as the result. + /// This function constructs a plain JSON payload containing the session ID and query, + /// sends it to the `query_handler` API, and returns the decoded JSON response. pub async fn query( &self, model: &str, query: &str, traits: &str, mid: &str, context: Value, ) -> Result { From dcd06f4af76c75f05281b0d38f064cccaa56de6f Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 01:25:40 +0100 Subject: [PATCH 03/43] Update docs --- docs/conf.py | 6 ++++ docs/genusage/virtual_minions.rst | 2 +- docs/global_config.rst | 48 +++++++++++++++++++++++++++++++ docs/requirements.txt | 1 + 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 1347bd29..17c44fe1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,9 @@ +"""Sphinx configuration for the Sysinspect documentation.""" + +from __future__ import annotations + +# pylint: disable=invalid-name,redefined-builtin + project = "Sysinspect" copyright = "2026, Bo Maryniuk" author = "Bo Maryniuk" diff --git a/docs/genusage/virtual_minions.rst b/docs/genusage/virtual_minions.rst index 1a4f6395..fadc19ef 100644 --- a/docs/genusage/virtual_minions.rst +++ b/docs/genusage/virtual_minions.rst @@ -16,7 +16,7 @@ .. role:: bi :class: bolditalic -.. _global_configuration: +.. _virtual_minions: Clusters and Virtual Minions ============================ diff --git a/docs/global_config.rst b/docs/global_config.rst index ee1e8d6e..44850307 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -339,6 +339,54 @@ Below are directives for the configuration of the File Server service: Default is ``false``. +``api.tls.enabled`` +################### + + Type: **boolean** + + Turn on TLS for the embedded Web API listener. + + Default is ``false``. + +``api.tls.cert-file`` +##################### + + Type: **string** + + Path to the TLS certificate file for the Web API. + + If the path is relative, it is resolved under the Sysinspect root. If it is + absolute, it is used as-is. + +``api.tls.key-file`` +#################### + + Type: **string** + + Path to the TLS private key file for the Web API. + + If the path is relative, it is resolved under the Sysinspect root. If it is + absolute, it is used as-is. + +``api.tls.ca-file`` +################### + + Type: **string** + + Optional CA bundle path for TLS validation or future mutual-TLS use. + + If the path is relative, it is resolved under the Sysinspect root. If it is + absolute, it is used as-is. + +``api.tls.trust-self-signed`` +############################# + + Type: **boolean** + + Explicitly allow self-signed or otherwise non-standard TLS setups. + + Default is ``false``. + ``telemetry.location`` ###################### diff --git a/docs/requirements.txt b/docs/requirements.txt index 51eebd03..75c98b7b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ +sphinx myst_parser sphinx_rtd_theme From 6a1e36a7f105fd1a19e926345e30a2563ac36c01 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 01:26:06 +0100 Subject: [PATCH 04/43] dotfiles --- .gitignore | 1 + .vscode/settings.json | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 1f3fb695..2708c5c0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ package/ **/build/ TODO.txt DESIGN.txt +.vscode/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 0843dda9..137a79a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,10 +3,19 @@ "libsysinspect", "Sysinspect" ], + "python.defaultInterpreterPath": "/usr/bin/python3", "files.associations": { "*.cfg": "yaml" }, "makefile.configureOnOpen": false, + "esbonio.sphinx.confDir": "${workspaceFolder}/docs", + "esbonio.sphinx.srcDir": "${workspaceFolder}/docs", "python-envs.defaultEnvManager": "ms-python.python:system", - "python-envs.pythonProjects": [] -} \ No newline at end of file + "python-envs.pythonProjects": [], + "esbonio.sphinx.pythonCommand": { + "command": [ + "/usr/bin/python3" + ], + "cwd": "${workspaceFolder}" + } +} From d08f03f5a2a7897f0c05ec14e2039332a37bdaaf Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 14:33:53 +0100 Subject: [PATCH 05/43] Remove the Web API application-layer libsodium --- libdatastore/src/resources.rs | 59 +++++++++++++++++++++++++++++- libwebapi/src/api/v1/minions.rs | 64 +++++++-------------------------- libwebapi/src/api/v1/mod.rs | 19 +++------- libwebapi/src/lib.rs | 8 ++--- sysmaster/src/master.rs | 2 +- 5 files changed, 80 insertions(+), 72 deletions(-) diff --git a/libdatastore/src/resources.rs b/libdatastore/src/resources.rs index ae0eb669..d5bae4c5 100644 --- a/libdatastore/src/resources.rs +++ b/libdatastore/src/resources.rs @@ -2,12 +2,14 @@ use crate::{ cfg::DataStorageConfig, util::{copy, data_tree, get_sha256, json_write, meta_tree, unix_now}, }; +use colored::Colorize; use serde::{Deserialize, Serialize}; use std::os::unix::fs::MetadataExt; use std::{ fs, io::{self}, path::{Path, PathBuf}, + time::Duration, }; #[derive(Debug, Clone)] @@ -26,10 +28,65 @@ pub struct DataItemMeta { pub fmode: u32, } +/// Format a byte count into a short human-readable string. +fn format_bytes(bytes: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = 1024 * KIB; + const GIB: u64 = 1024 * MIB; + const TIB: u64 = 1024 * GIB; + + if bytes >= TIB { + format!("{:.1} TiB", bytes as f64 / TIB as f64) + } else if bytes >= GIB { + format!("{:.1} GiB", bytes as f64 / GIB as f64) + } else if bytes >= MIB { + format!("{:.1} MiB", bytes as f64 / MIB as f64) + } else if bytes >= KIB { + format!("{:.1} KiB", bytes as f64 / KIB as f64) + } else { + format!("{bytes} B") + } +} + +/// Format an optional size limit for operator-facing logging. +fn format_size_limit(bytes: Option) -> String { + bytes.map(format_bytes).unwrap_or_else(|| "unlimited".to_string()) +} + +/// Format an optional expiration duration for operator-facing logging. +fn format_expiration(expiration: Option) -> String { + match expiration { + Some(duration) => { + let total = duration.as_secs(); + let days = total / 86_400; + let hours = (total % 86_400) / 3_600; + let minutes = (total % 3_600) / 60; + let seconds = total % 60; + + if days > 0 { + format!("{days}d {hours}h") + } else if hours > 0 { + format!("{hours}h {minutes}m") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } + } + None => "never".to_string(), + } +} + impl DataStorage { pub fn new(cfg: DataStorageConfig, root: impl AsRef) -> io::Result { let root = root.as_ref().to_path_buf(); - log::info!("Initializing datastore at {:?} with config: {:?}", root, cfg); + log::info!( + "Initializing datastore at {}. Expiration: {}, max item size: {}, max overall size: {}", + root.display().to_string().bright_yellow(), + format_expiration(cfg.get_expiration()).bright_yellow(), + format_size_limit(cfg.get_max_item_size()).bright_yellow(), + format_size_limit(cfg.get_max_overall_size()).bright_yellow(), + ); fs::create_dir_all(&root)?; Ok(Self { cfg, root }) } diff --git a/libwebapi/src/api/v1/minions.rs b/libwebapi/src/api/v1/minions.rs index 72504537..ace9b417 100644 --- a/libwebapi/src/api/v1/minions.rs +++ b/libwebapi/src/api/v1/minions.rs @@ -9,28 +9,6 @@ use serde::{Deserialize, Serialize}; use std::{collections::HashMap, fmt::Display}; use utoipa::ToSchema; -#[derive(Deserialize, Serialize, ToSchema)] -pub struct QueryPayloadRequest { - pub model: String, - pub query: String, - pub traits: String, - pub mid: String, - pub context: HashMap, -} - -impl QueryPayloadRequest { - pub fn to_query(&self) -> String { - format!( - "{};{};{};{};{}", - self.model, - self.query, - self.traits, - self.mid, - self.context.iter().map(|(k, v)| format!("{k}:{v}")).collect::>().join(",") - ) - } -} - #[derive(Deserialize, Serialize, ToSchema)] pub struct QueryRequest { pub sid: String, @@ -42,20 +20,22 @@ pub struct QueryRequest { } impl QueryRequest { - pub fn to_query_request(&self) -> Result { + /// Validate the session and convert the request into the internal query format. + pub fn to_query(&self) -> Result { if self.sid.trim().is_empty() { return Err(SysinspectError::WebAPIError("Session ID cannot be empty".to_string())); } let mut sessions = get_session_store().lock().unwrap(); match sessions.uid(&self.sid) { - Some(_) => Ok(QueryPayloadRequest { - model: self.model.clone(), - query: self.query.clone(), - traits: self.traits.clone(), - mid: self.mid.clone(), - context: self.context.clone(), - }), + Some(_) => Ok(format!( + "{};{};{};{};{}", + self.model, + self.query, + self.traits, + self.mid, + self.context.iter().map(|(k, v)| format!("{k}:{v}")).collect::>().join(",") + )), None => { log::debug!("Session {} is missing or expired", self.sid); Err(SysinspectError::WebAPIError("Invalid or expired session".to_string())) @@ -94,7 +74,7 @@ impl Display for QueryError { #[post("/api/v1/query")] async fn query_handler(master: Data, body: Json) -> Result> { let mut master = master.lock().await; - let qpr = match body.to_query_request() { + let query = match body.to_query() { Ok(q) => q, Err(e) => { use actix_web::http::StatusCode; @@ -103,27 +83,7 @@ async fn query_handler(master: Data, body: Json Ok(Json(QueryResponse { status: "success".to_string(), message: "Query executed successfully".to_string() })), - Err(err) => Ok(Json(QueryResponse { status: "error".to_string(), message: err.to_string() })), - } -} - -#[utoipa::path( - post, - path = "/api/v1/dev_query", - request_body = QueryPayloadRequest, - description = "Development endpoint for querying minions. FOR DEVELOPMENT AND DEBUGGING PURPOSES ONLY!", - tag = TAG_MINIONS, - responses( - (status = 200, description = "Success", body = QueryResponse), - (status = 400, description = "Bad Request", body = QueryError) - ) -)] -#[post("/api/v1/dev_query")] -async fn query_handler_dev(master: Data, body: Json) -> Result> { - let mut master = master.lock().await; - match master.query(body.to_query()).await { + match master.query(query).await { Ok(()) => Ok(Json(QueryResponse { status: "success".to_string(), message: "Query executed successfully".to_string() })), Err(err) => Ok(Json(QueryResponse { status: "error".to_string(), message: err.to_string() })), } diff --git a/libwebapi/src/api/v1/mod.rs b/libwebapi/src/api/v1/mod.rs index 74c00007..0ee5961a 100644 --- a/libwebapi/src/api/v1/mod.rs +++ b/libwebapi/src/api/v1/mod.rs @@ -1,6 +1,6 @@ pub use crate::api::v1::system::health_handler; use crate::api::v1::{ - minions::{QueryError, QueryPayloadRequest, QueryRequest, QueryResponse, query_handler, query_handler_dev}, + minions::{QueryError, QueryRequest, QueryResponse, query_handler}, model::{ModelNameResponse, model_descr_handler, model_names_handler}, store::{ StoreListQuery, StoreMetaResponse, StoreResolveQuery, store_blob_handler, store_list_handler, store_meta_handler, store_resolve_handler, @@ -37,8 +37,7 @@ impl V1 { impl super::ApiVersion for V1 { fn load(&self, scope: Scope) -> Scope { - let mut scope = scope - // Available services + scope .service(query_handler) .service(health_handler) .service(authenticate_handler) @@ -53,20 +52,13 @@ impl super::ApiVersion for V1 { SwaggerUi::new("/doc/{_:.*}").url("/api-doc/openapi.json", ApiDocDev::openapi()) } else { SwaggerUi::new("/doc/{_:.*}").url("/api-doc/openapi.json", ApiDoc::openapi()) - }); - - if self.dev_mode { - scope = scope.service(query_handler_dev); - } - - scope + }) } } #[derive(OpenApi)] #[openapi(paths( crate::api::v1::minions::query_handler, - crate::api::v1::minions::query_handler_dev, crate::api::v1::system::health_handler, crate::api::v1::system::authenticate_handler, crate::api::v1::model::model_names_handler, @@ -77,7 +69,7 @@ impl super::ApiVersion for V1 { crate::api::v1::store::store_resolve_handler, crate::api::v1::store::store_list_handler, ), - components(schemas(QueryRequest, QueryResponse, QueryError, QueryPayloadRequest, + components(schemas(QueryRequest, QueryResponse, QueryError, HealthInfo, HealthResponse, AuthRequest, AuthResponse, ModelNameResponse, StoreMetaResponse, StoreResolveQuery, StoreListQuery)), info(title = "SysInspect API", version = API_VERSION, description = "SysInspect Web API for interacting with the master interface."))] @@ -86,7 +78,6 @@ pub struct ApiDoc; #[derive(OpenApi)] #[openapi(paths( crate::api::v1::minions::query_handler, - crate::api::v1::minions::query_handler_dev, crate::api::v1::system::health_handler, crate::api::v1::system::authenticate_handler, crate::api::v1::model::model_names_handler, @@ -97,7 +88,7 @@ pub struct ApiDoc; crate::api::v1::store::store_resolve_handler, crate::api::v1::store::store_list_handler, ), - components(schemas(QueryRequest, QueryResponse, QueryError, QueryPayloadRequest, + components(schemas(QueryRequest, QueryResponse, QueryError, HealthInfo, HealthResponse, AuthRequest, AuthResponse, ModelNameResponse, StoreMetaResponse, StoreResolveQuery, StoreListQuery)), info(title = "SysInspect API", version = API_VERSION, description = "SysInspect Web API for interacting with the master interface."))] diff --git a/libwebapi/src/lib.rs b/libwebapi/src/lib.rs index 6572046f..b65aecd3 100644 --- a/libwebapi/src/lib.rs +++ b/libwebapi/src/lib.rs @@ -37,9 +37,9 @@ fn advertised_doc_url(bind_addr: &str, bind_port: u32) -> String { format!("http://{}:{bind_port}/doc/", advertised_api_host(bind_addr)) } -pub fn start_webapi(cfg: MasterConfig, master: MasterInterfaceType) -> Result<(), SysinspectError> { +pub fn start_embedded_webapi(cfg: MasterConfig, master: MasterInterfaceType) -> Result<(), SysinspectError> { if !cfg.api_enabled() { - log::info!("Web API disabled."); + log::info!("Embedded Web API disabled."); return Ok(()); } @@ -56,8 +56,8 @@ pub fn start_webapi(cfg: MasterConfig, master: MasterInterfaceType) -> Result<() _ => ApiVersions::V1, }; - log::info!("Starting Web API at {}", listen_addr.bright_yellow()); - log::info!("Web API enabled. Swagger UI available at {}", advertised_doc_url(&bind_addr, bind_port)); + log::info!("Starting embedded Web API inside sysmaster at {}", listen_addr.bright_yellow()); + log::info!("Embedded Web API enabled. Swagger UI available at {}", advertised_doc_url(&bind_addr, bind_port)); actix_web::rt::System::new().block_on(async move { HttpServer::new(move || { diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 630a4ae1..b574c32d 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -1211,7 +1211,7 @@ pub(crate) async fn master(cfg: MasterConfig) -> Result<(), SysinspectError> { log::info!("Fileserver started on directory {}", cfg.fileserver_root().to_str().unwrap_or_default()); // Start web API (if configured/enabled) - libwebapi::start_webapi(cfg.clone(), master.clone())?; + libwebapi::start_embedded_webapi(cfg.clone(), master.clone())?; // Start services let ipc = SysMaster::do_ipc_service(Arc::clone(&master)).await; From 2af16b48c92fcff9028a2b840b721043c20b98ae Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 14:34:00 +0100 Subject: [PATCH 06/43] Update makefile --- Makefile | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 2177be40..3a8bd304 100644 --- a/Makefile +++ b/Makefile @@ -91,19 +91,18 @@ man: pandoc --standalone --to man docs/manpages/sysinspect.8.md -o docs/manpages/sysinspect.8 test: setup - CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run --workspace $(PLATFORM_WORKSPACE_EXCLUDES) --test-threads $(TEST_RUN_THREADS) - + @CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run --workspace $(PLATFORM_WORKSPACE_EXCLUDES) --test-threads $(TEST_RUN_THREADS) test-core: setup - CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(foreach pkg,$(CORE_PACKAGE_SPECS),-p $(pkg)) --lib --bins --test-threads $(TEST_RUN_THREADS) + @CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(foreach pkg,$(CORE_PACKAGE_SPECS),-p $(pkg)) --lib --bins --test-threads $(TEST_RUN_THREADS) test-modules: setup - CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(foreach pkg,$(MODULE_PACKAGE_SPECS),-p $(pkg)) --bins --test-threads $(TEST_RUN_THREADS) + @CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(foreach pkg,$(MODULE_PACKAGE_SPECS),-p $(pkg)) --bins --test-threads $(TEST_RUN_THREADS) test-sensors: setup - CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(foreach pkg,$(SENSOR_PACKAGE_SPECS),-p $(pkg)) --lib --bins --test-threads $(TEST_RUN_THREADS) + @CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(foreach pkg,$(SENSOR_PACKAGE_SPECS),-p $(pkg)) --lib --bins --test-threads $(TEST_RUN_THREADS) test-integration: setup - CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(INTEGRATION_TEST_TARGETS) --test-threads $(TEST_RUN_THREADS) + @CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(INTEGRATION_TEST_TARGETS) --test-threads $(TEST_RUN_THREADS) tar: # Cleanup From d0392668793063e3e1457707ba7412d2d045f8c3 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 14:34:07 +0100 Subject: [PATCH 07/43] Update docs --- docs/global_config.rst | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/global_config.rst b/docs/global_config.rst index 44850307..02863986 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -280,7 +280,11 @@ Below are directives for the configuration of the File Server service: Type: **boolean** - Enable or disable the WebAPI service to control Sysinspect Master remotely. + Enable or disable the embedded Web API listener inside the ``sysmaster`` + process so Sysinspect Master can be controlled remotely. + + This listener is part of ``sysmaster`` itself. Sysinspect does not start a + separate Web API daemon for this interface. .. important:: @@ -301,7 +305,7 @@ Below are directives for the configuration of the File Server service: Type: **string** - IPv4 address on which the WebAPI service is listening for all incoming and outgoing traffic. + IPv4 address on which the embedded Web API listener accepts traffic. Default value is ``0.0.0.0``. @@ -310,16 +314,16 @@ Below are directives for the configuration of the File Server service: Type: **integer** - Network port number on which the WebAPI service is listening. + Network port number on which the embedded Web API listener is listening. - WebAPI service port is ``4202``. + The embedded Web API listener uses port ``4202`` by default. ``api.auth`` ############ Type: **string** - Authentication method to be used for the WebAPI service. This is a string and can be one of the following: + Authentication method to be used for the embedded Web API. This is a string and can be one of the following: - ``pam`` - ``ldap`` `(planned, not implemented yet)` @@ -329,7 +333,7 @@ Below are directives for the configuration of the File Server service: Type: **boolean** - Enable or disable development-only helpers for the WebAPI service. + Enable or disable development-only helpers for the embedded Web API. .. danger:: From abf43f46b89ce363f647846301708c6351065313 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 14:58:04 +0100 Subject: [PATCH 08/43] Add dev-tls target to Makefile --- Makefile | 5 ++++- scripts/dev-tls.sh | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100755 scripts/dev-tls.sh diff --git a/Makefile b/Makefile index 3a8bd304..2132f327 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ include Makefile.in .PHONY: build devel all all-devel modules modules-dev modules-dist-devel modules-refresh-devel clean check fix setup \ musl-aarch64-dev musl-aarch64 musl-x86_64-dev musl-x86_64 \ - stats man test test-core test-modules test-sensors test-integration tar + stats man test test-core test-modules test-sensors test-integration tar dev-tls setup: $(call deps) @@ -90,6 +90,9 @@ stats: man: pandoc --standalone --to man docs/manpages/sysinspect.8.md -o docs/manpages/sysinspect.8 +dev-tls: + ./scripts/dev-tls.sh + test: setup @CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run --workspace $(PLATFORM_WORKSPACE_EXCLUDES) --test-threads $(TEST_RUN_THREADS) test-core: setup diff --git a/scripts/dev-tls.sh b/scripts/dev-tls.sh new file mode 100755 index 00000000..57fac785 --- /dev/null +++ b/scripts/dev-tls.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env sh +set -eu + +OUT_DIR="${1:-target/dev-tls}" +CERT_FILE="${OUT_DIR}/sysmaster-dev.crt" +KEY_FILE="${OUT_DIR}/sysmaster-dev.key" +DAYS="${DEV_TLS_DAYS:-365}" + +mkdir -p "${OUT_DIR}" + +openssl req \ + -x509 \ + -newkey rsa:4096 \ + -sha256 \ + -days "${DAYS}" \ + -nodes \ + -keyout "${KEY_FILE}" \ + -out "${CERT_FILE}" \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + +chmod 600 "${KEY_FILE}" +chmod 644 "${CERT_FILE}" + +cat < Date: Sat, 21 Mar 2026 14:58:12 +0100 Subject: [PATCH 09/43] Update dependencies --- Cargo.lock | 23 +++++++++++++++++++++++ libwebapi/Cargo.toml | 4 +++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index bd467900..11ffd76b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,7 @@ dependencies = [ "actix-codec", "actix-rt", "actix-service", + "actix-tls", "actix-utils", "base64", "bitflags 2.11.0", @@ -143,6 +144,25 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "actix-tls" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "impl-more", + "pin-project-lite", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", +] + [[package]] name = "actix-utils" version = "3.0.1" @@ -166,6 +186,7 @@ dependencies = [ "actix-rt", "actix-server", "actix-service", + "actix-tls", "actix-utils", "actix-web-codegen", "bytes", @@ -4015,6 +4036,8 @@ dependencies = [ "once_cell", "pam", "reqwest 0.12.28", + "rustls", + "rustls-pemfile", "serde", "serde_json", "serde_yaml", diff --git a/libwebapi/Cargo.toml b/libwebapi/Cargo.toml index 6e937a98..b2aa2c5c 100644 --- a/libwebapi/Cargo.toml +++ b/libwebapi/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -actix-web = "4.12.1" +actix-web = { version = "4.12.1", features = ["rustls-0_23"] } reqwest = { version = "0.12.28", features = ["blocking", "json"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" @@ -30,3 +30,5 @@ actix-files = "0.6.10" tempfile = "3.25.0" futures-util = "0.3.32" hostname = "0.4.1" +rustls = "0.23.36" +rustls-pemfile = "2.2.0" From 54303f3a32ef20fc5070c017c2783343d1e86d6a Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 14:58:26 +0100 Subject: [PATCH 10/43] Update docs for WebAPI TLS --- docs/global_config.rst | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/global_config.rst b/docs/global_config.rst index 02863986..e78695ff 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -348,7 +348,7 @@ Below are directives for the configuration of the File Server service: Type: **boolean** - Turn on TLS for the embedded Web API listener. + Enable TLS for the embedded Web API listener. Default is ``false``. @@ -357,37 +357,41 @@ Below are directives for the configuration of the File Server service: Type: **string** - Path to the TLS certificate file for the Web API. + Path to the PEM certificate chain used by the Web API TLS listener. If the path is relative, it is resolved under the Sysinspect root. If it is absolute, it is used as-is. + When ``api.tls.enabled`` is ``true``, this option is required. + ``api.tls.key-file`` #################### Type: **string** - Path to the TLS private key file for the Web API. + Path to the PEM private key used by the Web API TLS listener. If the path is relative, it is resolved under the Sysinspect root. If it is absolute, it is used as-is. + When ``api.tls.enabled`` is ``true``, this option is required. + ``api.tls.ca-file`` ################### Type: **string** - Optional CA bundle path for TLS validation or future mutual-TLS use. + Optional CA bundle path used for TLS validation or mutual TLS extensions. If the path is relative, it is resolved under the Sysinspect root. If it is absolute, it is used as-is. -``api.tls.trust-self-signed`` -############################# +``api.tls.allow-insecure`` +########################## Type: **boolean** - Explicitly allow self-signed or otherwise non-standard TLS setups. + Allow explicitly using insecure client trust handling for the Web API TLS setup. Default is ``false``. From b0f8129a6c04518809090ef65bc5a57e3bf4bfa2 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 14:58:47 +0100 Subject: [PATCH 11/43] Update config, make better logging --- libsysinspect/src/cfg/mmconf.rs | 12 ++-- libwebapi/src/lib.rs | 111 ++++++++++++++++++++++++++++---- 2 files changed, 105 insertions(+), 18 deletions(-) diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index a3ef99d7..a4efa61a 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -841,9 +841,9 @@ pub struct MasterConfig { #[serde(rename = "api.tls.ca-file")] api_tls_ca_file: Option, - /// Allow explicitly trusting self-signed or non-standard TLS setups. - #[serde(rename = "api.tls.trust-self-signed")] - api_tls_trust_self_signed: Option, + /// Allow explicitly using insecure client trust handling for the Web API TLS setup. + #[serde(rename = "api.tls.allow-insecure")] + api_tls_allow_insecure: Option, /// Enable development-only Web API shortcuts. /// @@ -1036,9 +1036,9 @@ impl MasterConfig { self.api_tls_ca_file.as_deref().map(|path| self.resolve_rooted_path(path)) } - /// Return whether the Web API may explicitly trust self-signed TLS setups. - pub fn api_tls_trust_self_signed(&self) -> bool { - self.api_tls_trust_self_signed.unwrap_or(false) + /// Return whether the Web API may explicitly use insecure client trust handling. + pub fn api_tls_allow_insecure(&self) -> bool { + self.api_tls_allow_insecure.unwrap_or(false) } /// Get API authentication method diff --git a/libwebapi/src/lib.rs b/libwebapi/src/lib.rs index b65aecd3..2d8e3275 100644 --- a/libwebapi/src/lib.rs +++ b/libwebapi/src/lib.rs @@ -4,10 +4,13 @@ use colored::Colorize; use libcommon::SysinspectError; use libdatastore::resources::DataStorage; use libsysinspect::cfg::mmconf::MasterConfig; -use std::{sync::Arc, thread}; +use rustls::ServerConfig; +use std::{fs::File, io::BufReader, sync::Arc, thread}; use tokio::sync::Mutex; pub mod api; +#[cfg(test)] +mod lib_ut; pub mod pamauth; pub mod sessions; @@ -20,6 +23,7 @@ pub trait MasterInterface: Send + Sync { pub type MasterInterfaceType = Arc>; +/// Determines the advertised API host for the Web API based on the bind address. fn advertised_api_host(bind_addr: &str) -> String { match bind_addr { "0.0.0.0" | "::" | "[::]" => hostname::get() @@ -33,16 +37,97 @@ fn advertised_api_host(bind_addr: &str) -> String { } } -fn advertised_doc_url(bind_addr: &str, bind_port: u32) -> String { - format!("http://{}:{bind_port}/doc/", advertised_api_host(bind_addr)) +/// Constructs the advertised documentation URL for the Web API based on the bind address, port, and TLS configuration. +pub(crate) fn advertised_doc_url(bind_addr: &str, bind_port: u32, tls_enabled: bool) -> String { + let scheme = if tls_enabled { "https" } else { "http" }; + format!("{scheme}://{}:{bind_port}/doc/", advertised_api_host(bind_addr)) } +/// Returns a user-friendly error message about TLS setup for WebAPI, pointing to the relevant documentation section. +pub(crate) fn tls_setup_err_message() -> String { + format!( + "TLS is not setup for WebAPI. For more information, see Documentation chapter \"{}\", section \"{}\".", + "Configuration".bright_yellow(), + "api.tls.enabled".bright_yellow() + ) +} + +/// Loads the TLS server configuration for the Web API from the provided MasterConfig. +/// This includes reading the certificate and private key files, and optionally +/// the CA file if client certificate authentication is configured. +/// Returns a ServerConfig on success, or a SysinspectError with a user-friendly message on failure. +fn load_tls_server_config(cfg: &MasterConfig) -> Result { + let cert_path = cfg + .api_tls_cert_file() + .ok_or_else(|| SysinspectError::ConfigError("Web API TLS is enabled, but api.tls.cert-file is not configured".to_string()))?; + let key_path = cfg + .api_tls_key_file() + .ok_or_else(|| SysinspectError::ConfigError("Web API TLS is enabled, but api.tls.key-file is not configured".to_string()))?; + + let mut cert_reader = BufReader::new( + File::open(&cert_path) + .map_err(|err| SysinspectError::ConfigError(format!("Unable to open Web API TLS certificate file {}: {err}", cert_path.display())))?, + ); + let certs = rustls_pemfile::certs(&mut cert_reader) + .collect::, _>>() + .map_err(|err| SysinspectError::ConfigError(format!("Unable to read Web API TLS certificate file {}: {err}", cert_path.display())))?; + if certs.is_empty() { + return Err(SysinspectError::ConfigError(format!( + "Web API TLS certificate file {} does not contain any PEM certificates", + cert_path.display() + ))); + } + + let mut key_reader = BufReader::new( + File::open(&key_path) + .map_err(|err| SysinspectError::ConfigError(format!("Unable to open Web API TLS private key file {}: {err}", key_path.display())))?, + ); + let private_key = rustls_pemfile::private_key(&mut key_reader) + .map_err(|err| SysinspectError::ConfigError(format!("Unable to read Web API TLS private key file {}: {err}", key_path.display())))? + .ok_or_else(|| { + SysinspectError::ConfigError(format!("Web API TLS private key file {} does not contain a supported PEM private key", key_path.display())) + })?; + + if let Some(ca_path) = cfg.api_tls_ca_file() { + let mut ca_reader = BufReader::new( + File::open(&ca_path) + .map_err(|err| SysinspectError::ConfigError(format!("Unable to open Web API TLS CA file {}: {err}", ca_path.display())))?, + ); + let ca_certs = rustls_pemfile::certs(&mut ca_reader) + .collect::, _>>() + .map_err(|err| SysinspectError::ConfigError(format!("Unable to read Web API TLS CA file {}: {err}", ca_path.display())))?; + if ca_certs.is_empty() { + return Err(SysinspectError::ConfigError(format!("Web API TLS CA file {} does not contain any PEM certificates", ca_path.display()))); + } + } + + ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, private_key) + .map_err(|err| SysinspectError::ConfigError(format!("Invalid Web API TLS certificate/private key pair: {err}"))) +} + +/// Starts the embedded Web API server in a new thread, using the provided MasterConfig and MasterInterface. pub fn start_embedded_webapi(cfg: MasterConfig, master: MasterInterfaceType) -> Result<(), SysinspectError> { if !cfg.api_enabled() { log::info!("Embedded Web API disabled."); return Ok(()); } + if !cfg.api_tls_enabled() { + log::error!("{}", tls_setup_err_message()); + return Ok(()); + } + + let tls_config = match load_tls_server_config(&cfg) { + Ok(tls_config) => tls_config, + Err(err) => { + log::error!("{}", tls_setup_err_message()); + log::error!("Embedded Web API TLS setup error: {err}"); + return Ok(()); + } + }; + let ccfg = cfg.clone(); let cmaster = master.clone(); @@ -56,22 +141,24 @@ pub fn start_embedded_webapi(cfg: MasterConfig, master: MasterInterfaceType) -> _ => ApiVersions::V1, }; - log::info!("Starting embedded Web API inside sysmaster at {}", listen_addr.bright_yellow()); - log::info!("Embedded Web API enabled. Swagger UI available at {}", advertised_doc_url(&bind_addr, bind_port)); + log::info!("Starting embedded Web API inside sysmaster at {} over {}", listen_addr.bright_yellow(), "HTTPS/TLS"); + log::info!("Embedded Web API enabled. Swagger UI available at {}", advertised_doc_url(&bind_addr, bind_port, true).bright_yellow()); + if ccfg.api_tls_allow_insecure() { + log::warn!("Web API TLS allow-insecure mode is enabled for clients."); + } actix_web::rt::System::new().block_on(async move { - HttpServer::new(move || { + let server = HttpServer::new(move || { let mut scope = web::scope(""); if let Some(ver) = api::get(devmode, version) { scope = ver.load(scope); } App::new().app_data(web::Data::new(cmaster.clone())).service(scope) - }) - .bind((bind_addr.as_str(), bind_port as u16)) - .map_err(SysinspectError::from)? - .run() - .await - .map_err(SysinspectError::from) + }); + + let server = server.bind_rustls_0_23((bind_addr.as_str(), bind_port as u16), tls_config).map_err(SysinspectError::from)?; + + server.run().await.map_err(SysinspectError::from) }) }); From 29d8500b50ce19e29c12b799d8c6124b24033eb5 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 14:58:54 +0100 Subject: [PATCH 12/43] Add more unit tests --- libsysinspect/src/cfg/mmconf_ut.rs | 6 +++--- libwebapi/src/lib_ut.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 libwebapi/src/lib_ut.rs diff --git a/libsysinspect/src/cfg/mmconf_ut.rs b/libsysinspect/src/cfg/mmconf_ut.rs index 275c3df7..76009976 100644 --- a/libsysinspect/src/cfg/mmconf_ut.rs +++ b/libsysinspect/src/cfg/mmconf_ut.rs @@ -59,7 +59,7 @@ fn master_transport_paths_are_under_managed_transport_root() { #[test] fn master_api_tls_relative_paths_are_resolved_under_root() { let cfg = MasterConfig::new(write_master_cfg( - "config:\n master:\n fileserver.models: []\n api.tls.enabled: true\n api.tls.cert-file: etc/web/api.crt\n api.tls.key-file: etc/web/api.key\n api.tls.ca-file: trust/ca.pem\n api.tls.trust-self-signed: true\n", + "config:\n master:\n fileserver.models: []\n api.tls.enabled: true\n api.tls.cert-file: etc/web/api.crt\n api.tls.key-file: etc/web/api.key\n api.tls.ca-file: trust/ca.pem\n api.tls.allow-insecure: true\n", )) .unwrap(); @@ -67,7 +67,7 @@ fn master_api_tls_relative_paths_are_resolved_under_root() { assert_eq!(cfg.api_tls_cert_file().unwrap(), cfg.root_dir().join("etc/web/api.crt")); assert_eq!(cfg.api_tls_key_file().unwrap(), cfg.root_dir().join("etc/web/api.key")); assert_eq!(cfg.api_tls_ca_file().unwrap(), cfg.root_dir().join("trust/ca.pem")); - assert!(cfg.api_tls_trust_self_signed()); + assert!(cfg.api_tls_allow_insecure()); } #[test] @@ -81,7 +81,7 @@ fn master_api_tls_absolute_paths_stay_absolute() { assert_eq!(cfg.api_tls_key_file().unwrap(), std::path::PathBuf::from("/srv/tls/api.key")); assert_eq!(cfg.api_tls_ca_file().unwrap(), std::path::PathBuf::from("/srv/tls/ca.pem")); assert!(!cfg.api_tls_enabled()); - assert!(!cfg.api_tls_trust_self_signed()); + assert!(!cfg.api_tls_allow_insecure()); } #[test] diff --git a/libwebapi/src/lib_ut.rs b/libwebapi/src/lib_ut.rs new file mode 100644 index 00000000..59a06111 --- /dev/null +++ b/libwebapi/src/lib_ut.rs @@ -0,0 +1,19 @@ +use super::{advertised_doc_url, tls_setup_err_message}; + +#[test] +fn advertised_doc_url_uses_http_without_tls() { + assert_eq!(advertised_doc_url("127.0.0.1", 4202, false), "http://127.0.0.1:4202/doc/"); +} + +#[test] +fn advertised_doc_url_uses_https_with_tls() { + assert_eq!(advertised_doc_url("127.0.0.1", 4202, true), "https://127.0.0.1:4202/doc/"); +} + +#[test] +fn tls_not_setup_error_points_to_real_docs_section() { + assert_eq!( + tls_setup_err_message(), + "TLS is not setup for WebAPI. For more information, see Documentation chapter \"Configuration\", section \"api.tls.enabled\"." + ); +} From 2b5237d0224b3aaad7d0c285e5e3750f194842bd Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 15:06:31 +0100 Subject: [PATCH 13/43] Regenerate client over TLS --- libwebapi/src/api/v1/minions.rs | 68 ++++++++++++++++++++++----------- libwebapi/src/api/v1/mod.rs | 14 +++++++ libwebapi/src/api/v1/model.rs | 18 ++++++--- libwebapi/src/api/v1/store.rs | 32 +++++++++++++--- libwebapi/src/api/v1/system.rs | 42 +++++++++++--------- libwebapi/src/sessions.rs | 12 ++++++ sysclient/src/lib.rs | 38 +++++++++++------- sysclient/src/main.rs | 6 +-- 8 files changed, 161 insertions(+), 69 deletions(-) diff --git a/libwebapi/src/api/v1/minions.rs b/libwebapi/src/api/v1/minions.rs index ace9b417..52269e72 100644 --- a/libwebapi/src/api/v1/minions.rs +++ b/libwebapi/src/api/v1/minions.rs @@ -1,6 +1,7 @@ pub use crate::api::v1::system::health_handler; use crate::{MasterInterfaceType, api::v1::TAG_MINIONS, sessions::get_session_store}; use actix_web::{ + HttpRequest, Result, post, web::{Data, Json}, }; @@ -11,7 +12,6 @@ use utoipa::ToSchema; #[derive(Deserialize, Serialize, ToSchema)] pub struct QueryRequest { - pub sid: String, pub model: String, pub query: String, pub traits: String, @@ -20,27 +20,15 @@ pub struct QueryRequest { } impl QueryRequest { - /// Validate the session and convert the request into the internal query format. pub fn to_query(&self) -> Result { - if self.sid.trim().is_empty() { - return Err(SysinspectError::WebAPIError("Session ID cannot be empty".to_string())); - } - - let mut sessions = get_session_store().lock().unwrap(); - match sessions.uid(&self.sid) { - Some(_) => Ok(format!( - "{};{};{};{};{}", - self.model, - self.query, - self.traits, - self.mid, - self.context.iter().map(|(k, v)| format!("{k}:{v}")).collect::>().join(",") - )), - None => { - log::debug!("Session {} is missing or expired", self.sid); - Err(SysinspectError::WebAPIError("Invalid or expired session".to_string())) - } - } + Ok(format!( + "{};{};{};{};{}", + self.model, + self.query, + self.traits, + self.mid, + self.context.iter().map(|(k, v)| format!("{k}:{v}")).collect::>().join(",") + )) } } #[derive(Serialize, ToSchema)] @@ -61,18 +49,52 @@ impl Display for QueryError { } } +pub(crate) fn authorize_request(req: &HttpRequest) -> Result { + let header = req + .headers() + .get(actix_web::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| SysinspectError::WebAPIError("Missing Authorization header".to_string()))?; + let token = header + .strip_prefix("Bearer ") + .ok_or_else(|| SysinspectError::WebAPIError("Authorization header must use Bearer token".to_string()))? + .trim(); + if token.is_empty() { + return Err(SysinspectError::WebAPIError("Bearer token cannot be empty".to_string())); + } + + let mut sessions = get_session_store().lock().unwrap(); + match sessions.uid(token) { + Some(uid) => { + sessions.ping(token); + Ok(uid) + } + None => Err(SysinspectError::WebAPIError("Invalid or expired bearer token".to_string())), + } +} + #[utoipa::path( post, path = "/api/v1/query", request_body = QueryRequest, tag = TAG_MINIONS, + security( + ("bearer_auth" = []) + ), responses( (status = 200, description = "Success", body = QueryResponse), - (status = 400, description = "Bad Request", body = QueryError) + (status = 400, description = "Bad Request", body = QueryError), + (status = 401, description = "Unauthorized", body = QueryError) ) )] #[post("/api/v1/query")] -async fn query_handler(master: Data, body: Json) -> Result> { +async fn query_handler(req: HttpRequest, master: Data, body: Json) -> Result> { + if let Err(e) = authorize_request(&req) { + use actix_web::http::StatusCode; + let err_body = Json(QueryError { status: "error".to_string(), error: e.to_string() }); + return Err(actix_web::error::InternalError::new(err_body, StatusCode::UNAUTHORIZED).into()); + } + let mut master = master.lock().await; let query = match body.to_query() { Ok(q) => q, diff --git a/libwebapi/src/api/v1/mod.rs b/libwebapi/src/api/v1/mod.rs index 0ee5961a..eeb7c708 100644 --- a/libwebapi/src/api/v1/mod.rs +++ b/libwebapi/src/api/v1/mod.rs @@ -9,7 +9,9 @@ use crate::api::v1::{ system::{AuthRequest, AuthResponse, HealthInfo, HealthResponse, authenticate_handler}, }; use actix_web::Scope; +use utoipa::Modify; use utoipa::OpenApi; +use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; use utoipa_swagger_ui::SwaggerUi; pub mod minions; @@ -24,6 +26,16 @@ pub static TAG_MINIONS: &str = "Minions"; pub static TAG_SYSTEM: &str = "System"; pub static TAG_MODELS: &str = "Models"; +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme("bearer_auth", SecurityScheme::Http(HttpBuilder::new().scheme(HttpAuthScheme::Bearer).build())); + } + } +} + /// API Version 1 implementation pub struct V1 { dev_mode: bool, @@ -72,6 +84,7 @@ impl super::ApiVersion for V1 { components(schemas(QueryRequest, QueryResponse, QueryError, HealthInfo, HealthResponse, AuthRequest, AuthResponse, ModelNameResponse, StoreMetaResponse, StoreResolveQuery, StoreListQuery)), +modifiers(&SecurityAddon), info(title = "SysInspect API", version = API_VERSION, description = "SysInspect Web API for interacting with the master interface."))] pub struct ApiDoc; @@ -91,5 +104,6 @@ pub struct ApiDoc; components(schemas(QueryRequest, QueryResponse, QueryError, HealthInfo, HealthResponse, AuthRequest, AuthResponse, ModelNameResponse, StoreMetaResponse, StoreResolveQuery, StoreListQuery)), +modifiers(&SecurityAddon), info(title = "SysInspect API", version = API_VERSION, description = "SysInspect Web API for interacting with the master interface."))] pub struct ApiDocDev; diff --git a/libwebapi/src/api/v1/model.rs b/libwebapi/src/api/v1/model.rs index 76de0b7f..ad4ff39a 100644 --- a/libwebapi/src/api/v1/model.rs +++ b/libwebapi/src/api/v1/model.rs @@ -1,6 +1,6 @@ -use crate::{MasterInterfaceType, api::v1::TAG_MODELS}; +use crate::{MasterInterfaceType, api::v1::{TAG_MODELS, minions::authorize_request}}; use actix_web::{ - HttpResponse, Result, get, + HttpRequest, HttpResponse, Result, get, web::{Data, Json, Query}, }; use indexmap::IndexMap; @@ -92,15 +92,19 @@ pub struct ModelNameResponse { tag = TAG_MODELS, operation_id = "listModels", description = "Lists all available models in the SysInspect system. Each model includes details such as its name, description, version, maintainer, and statistics about its entities, actions, constraints, and events.", + security( + ("bearer_auth" = []) + ), responses( (status = 200, description = "List of available models", body = ModelNameResponse) ) )] #[allow(unused)] #[get("/api/v1/model/names")] -pub async fn model_names_handler(master: Data) -> Json { +pub async fn model_names_handler(req: HttpRequest, master: Data) -> Result> { + authorize_request(&req).map_err(actix_web::error::ErrorUnauthorized)?; let mut master = master.lock().await; - Json(ModelNameResponse { models: master.cfg().await.fileserver_models().to_owned() }) + Ok(Json(ModelNameResponse { models: master.cfg().await.fileserver_models().to_owned() })) } #[utoipa::path( get, @@ -108,6 +112,9 @@ pub async fn model_names_handler(master: Data) -> Json) -> Json, query: Query>) -> Result { +pub async fn model_descr_handler(req: HttpRequest, master: Data, query: Query>) -> Result { + authorize_request(&req).map_err(actix_web::error::ErrorUnauthorized)?; let mid = query.get("name").cloned().unwrap_or_default(); // Model Id if mid.is_empty() { return Ok(HttpResponse::BadRequest().json(ModelResponseError { error: "Missing 'name' query parameter".to_string() })); diff --git a/libwebapi/src/api/v1/store.rs b/libwebapi/src/api/v1/store.rs index 96ff1bb6..376b82f3 100644 --- a/libwebapi/src/api/v1/store.rs +++ b/libwebapi/src/api/v1/store.rs @@ -1,9 +1,9 @@ use std::path::{Path, PathBuf}; -use crate::MasterInterfaceType; +use crate::{MasterInterfaceType, api::v1::minions::authorize_request}; use actix_files::NamedFile; use actix_web::Result as ActixResult; -use actix_web::{HttpResponse, Responder, get, post, web}; +use actix_web::{HttpRequest, HttpResponse, Responder, get, post, web}; use futures_util::StreamExt; use libdatastore::resources::DataItemMeta; use serde::{Deserialize, Serialize}; @@ -55,6 +55,9 @@ fn get_meta_files(root: &Path, out: &mut Vec) -> std::io::Result<()> { get, path = "/store/{sha256}", tag = "Datastore", + security( + ("bearer_auth" = []) + ), params( ("sha256" = String, Path, description = "SHA256 of the stored object") ), @@ -65,7 +68,10 @@ fn get_meta_files(root: &Path, out: &mut Vec) -> std::io::Result<()> { ) )] #[get("/store/{sha256:[0-9a-fA-F]{64}}")] -pub async fn store_meta_handler(master: web::Data, sha256: web::Path) -> impl Responder { +pub async fn store_meta_handler(req: HttpRequest, master: web::Data, sha256: web::Path) -> impl Responder { + if let Err(err) = authorize_request(&req) { + return HttpResponse::Unauthorized().body(err.to_string()); + } let ds = { let m = master.lock().await; m.datastore().await @@ -91,6 +97,9 @@ pub async fn store_meta_handler(master: web::Data, sha256: get, path = "/store/{sha256}/blob", tag = "Datastore", + security( + ("bearer_auth" = []) + ), params( ("sha256" = String, Path, description = "SHA256 of the stored object") ), @@ -101,7 +110,8 @@ pub async fn store_meta_handler(master: web::Data, sha256: ) )] #[get("/store/{sha256:[0-9a-fA-F]{64}}/blob")] -pub async fn store_blob_handler(master: web::Data, sha256: web::Path) -> ActixResult { +pub async fn store_blob_handler(req: HttpRequest, master: web::Data, sha256: web::Path) -> ActixResult { + authorize_request(&req).map_err(actix_web::error::ErrorUnauthorized)?; let ds = { let m = master.lock().await; m.datastore().await @@ -121,6 +131,9 @@ pub async fn store_blob_handler(master: web::Data, sha256: post, path = "/store", tag = "Datastore", + security( + ("bearer_auth" = []) + ), request_body( content = Vec, content_type = "application/octet-stream", @@ -134,6 +147,9 @@ pub async fn store_blob_handler(master: web::Data, sha256: )] #[post("/store")] pub async fn store_upload_handler(req: actix_web::HttpRequest, master: web::Data, mut payload: web::Payload) -> impl Responder { + if let Err(err) = authorize_request(&req) { + return HttpResponse::Unauthorized().body(err.to_string()); + } // full path goes into fname (as you demanded) let origin = req.headers().get("X-Filename").and_then(|v| v.to_str().ok()).map(|s| s.to_string()); @@ -223,6 +239,9 @@ pub async fn store_upload_handler(req: actix_web::HttpRequest, master: web::Data get, path = "/store/resolve", tag = "Datastore", + security( + ("bearer_auth" = []) + ), params( ("fname" = String, Query, description = "Full path stored in metadata (meta.fname)") ), @@ -233,7 +252,10 @@ pub async fn store_upload_handler(req: actix_web::HttpRequest, master: web::Data ) )] #[get("/store/resolve")] -pub async fn store_resolve_handler(master: web::Data, q: web::Query) -> impl Responder { +pub async fn store_resolve_handler(req: HttpRequest, master: web::Data, q: web::Query) -> impl Responder { + if let Err(err) = authorize_request(&req) { + return HttpResponse::Unauthorized().body(err.to_string()); + } let (root, want) = { let m = master.lock().await; (m.cfg().await.datastore_path(), q.fname.clone()) diff --git a/libwebapi/src/api/v1/system.rs b/libwebapi/src/api/v1/system.rs index 87fef617..4c5a2b4f 100644 --- a/libwebapi/src/api/v1/system.rs +++ b/libwebapi/src/api/v1/system.rs @@ -75,38 +75,29 @@ impl AuthRequest { #[derive(ToSchema, Deserialize, Serialize)] pub struct AuthResponse { pub status: String, - pub sid: String, + pub access_token: String, + pub token_type: String, pub error: String, } impl AuthResponse { pub(crate) fn error(error: &str) -> Self { - AuthResponse { status: "error".into(), sid: String::new(), error: error.into() } + AuthResponse { status: "error".into(), access_token: String::new(), token_type: String::new(), error: error.into() } } } #[utoipa::path( post, path = "/api/v1/authenticate", - request_body = AuthRequest, + request_body = AuthRequest, responses( - (status = 200, description = "Authentication successful. Returns a plain session identifier.", - body = AuthResponse, example = json!({"status": "authenticated", "sid": "session-id", "error": ""})), + (status = 200, description = "Authentication successful. Returns a bearer token.", + body = AuthResponse, example = json!({"status": "authenticated", "access_token": "session-token", "token_type": "Bearer", "error": ""})), (status = 400, description = "Bad Request. Returned if payload is missing, invalid, or credentials are incorrect.", - body = AuthResponse, example = json!({"status": "error", "sid": "", "error": "Invalid payload"}))), + body = AuthResponse, example = json!({"status": "error", "access_token": "", "token_type": "", "error": "Invalid payload"}))), tag = TAG_SYSTEM, operation_id = "authenticateUser", - description = - "Authenticates a user using configured authentication method. The payload \ - is a plain JSON object with username and password fields as follows:\n\n\ - ```json\n\ - {\n\ - \"username\": \"darth_vader\",\n\ - \"password\": \"I am your father\"\n\ - }\n\ - ```\n\n\ - If `api.devmode` is enabled, the handler returns a static token without \ - performing authentication.", + description = "Authenticates a user using configured authentication method and returns a bearer token for subsequent HTTPS JSON requests.", )] #[post("/api/v1/authenticate")] pub async fn authenticate_handler(master: web::Data, body: web::Json) -> impl Responder { @@ -114,7 +105,15 @@ pub async fn authenticate_handler(master: web::Data, body: let cfg = master.cfg().await; if cfg.api_devmode() { log::warn!("Web API development auth bypass is enabled, returning static token."); - return HttpResponse::Ok().json(AuthResponse { status: "authenticated".into(), sid: "dev-token".into(), error: String::new() }); + return match get_session_store().lock().unwrap().open_with_sid("dev".into(), "dev-token".into()) { + Ok(token) => HttpResponse::Ok().json(AuthResponse { + status: "authenticated".into(), + access_token: token, + token_type: "Bearer".into(), + error: String::new(), + }), + Err(err) => HttpResponse::BadRequest().json(AuthResponse::error(&format!("Session error: {err}"))), + }; } if body.username.trim().is_empty() || body.password.trim().is_empty() { @@ -123,7 +122,12 @@ pub async fn authenticate_handler(master: web::Data, body: if cfg.api_auth() == Pam { match AuthRequest::pam_auth(body.username.clone(), body.password.clone()) { - Ok(sid) => HttpResponse::Ok().json(AuthResponse { status: "authenticated".into(), sid, error: String::new() }), + Ok(token) => HttpResponse::Ok().json(AuthResponse { + status: "authenticated".into(), + access_token: token, + token_type: "Bearer".into(), + error: String::new(), + }), Err(err) => HttpResponse::BadRequest().json(AuthResponse::error(&err)), } } else { diff --git a/libwebapi/src/sessions.rs b/libwebapi/src/sessions.rs index edca911e..dd2d9d54 100644 --- a/libwebapi/src/sessions.rs +++ b/libwebapi/src/sessions.rs @@ -57,6 +57,18 @@ impl SessionStore { Ok(sid) } + pub fn open_with_sid(&mut self, uid: String, sid: String) -> Result { + reap_expired!(self); + + if let Some(esid) = self.sessions.iter().find_map(|(existing_sid, s)| if s.uid == uid { Some(existing_sid.clone()) } else { None }) { + self.sessions.remove(&esid); + } + + self.sessions.insert(sid.clone(), Session { uid, created: Instant::now(), timeout: self.default_timeout }); + + Ok(sid) + } + /// Returns the user ID associated with the session ID, if it exists and not expired. /// If the session is expired, it will be removed from the store. /// Returns `None` if the session does not exist or is expired. diff --git a/sysclient/src/lib.rs b/sysclient/src/lib.rs index 572d6044..64056fb9 100644 --- a/sysclient/src/lib.rs +++ b/sysclient/src/lib.rs @@ -23,7 +23,7 @@ impl SysClientConfiguration { impl Default for SysClientConfiguration { fn default() -> Self { - SysClientConfiguration { master_url: "http://localhost:4202".to_string() } + SysClientConfiguration { master_url: "https://localhost:4202".to_string() } } } @@ -36,13 +36,13 @@ pub struct AuthRequest { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AuthResponse { pub status: String, - pub sid: String, + pub access_token: String, + pub token_type: String, pub error: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct QueryRequest { - pub sid: String, pub model: String, pub query: String, pub traits: String, @@ -83,16 +83,16 @@ pub struct ModelResponse { /// /// # Fields /// * `cfg` - The configuration for the SysClient, which includes the master URL. -/// * `sid` - The session ID for the authenticated user. +/// * `access_token` - The bearer token for the authenticated user. #[derive(Debug, Clone)] pub struct SysClient { cfg: SysClientConfiguration, - sid: String, + access_token: String, } impl SysClient { pub fn new(cfg: SysClientConfiguration) -> Self { - SysClient { cfg, sid: String::new() } + SysClient { cfg, access_token: String::new() } } /// Authenticate a user with the SysInspect system. @@ -123,7 +123,7 @@ impl SysClient { .await .map_err(|e| SysinspectError::MasterGeneralError(format!("Authentication decode error: {e}")))?; - if response.status != "authenticated" || response.sid.trim().is_empty() { + if response.status != "authenticated" || response.access_token.trim().is_empty() { return Err(SysinspectError::MasterGeneralError(if response.error.is_empty() { "Authentication failed".to_string() } else { @@ -131,14 +131,14 @@ impl SysClient { })); } - self.sid = response.sid; - log::debug!("Authenticated user: {uid}, session ID: {}", self.sid); + self.access_token = response.access_token; + log::debug!("Authenticated user: {uid}"); - Ok(self.sid.clone()) + Ok(self.access_token.clone()) } /// Query the SysInspect system with a given query string. - /// This method requires the client to be authenticated (i.e., `sid` must not be empty). + /// This method requires the client to be authenticated. /// /// # Arguments /// * `query` - The query string to send to the SysInspect system. @@ -151,17 +151,16 @@ impl SysClient { /// * Returns `SysinspectError::MasterGeneralError` if the client is not authenticated (i.e., `sid` is empty), /// * Returns `SysinspectError::MasterGeneralError` if there is an error during the query process, such as network issues or server errors. /// - /// This function constructs a plain JSON payload containing the session ID and query, + /// This function constructs a plain JSON payload containing the query, /// sends it to the `query_handler` API, and returns the decoded JSON response. pub async fn query( &self, model: &str, query: &str, traits: &str, mid: &str, context: Value, ) -> Result { - if self.sid.is_empty() { + if self.access_token.is_empty() { return Err(SysinspectError::MasterGeneralError("Client is not authenticated".to_string())); } let query_request = QueryRequest { - sid: self.sid.clone(), model: model.to_string(), query: query.to_string(), traits: traits.to_string(), @@ -173,6 +172,7 @@ impl SysClient { .cfg .client() .post(format!("{}/api/v1/query", self.cfg.master_url.trim_end_matches('/'))) + .bearer_auth(&self.access_token) .json(&query_request) .send() .await @@ -198,9 +198,14 @@ impl SysClient { /// Returns a `ModelNameResponse` containing the list of models on success, or a `SysinspectError` if the API call fails. /// This enables the caller to access the models provided by the SysInspect system. pub async fn models(&self) -> Result { + if self.access_token.is_empty() { + return Err(SysinspectError::MasterGeneralError("Client is not authenticated".to_string())); + } + self.cfg .client() .get(format!("{}/api/v1/model/names", self.cfg.master_url.trim_end_matches('/'))) + .bearer_auth(&self.access_token) .send() .await .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to list models: {e}")))? @@ -212,9 +217,14 @@ impl SysClient { } pub async fn model_descr(&self, name: &str) -> Result { + if self.access_token.is_empty() { + return Err(SysinspectError::MasterGeneralError("Client is not authenticated".to_string())); + } + self.cfg .client() .get(format!("{}/api/v1/model/descr", self.cfg.master_url.trim_end_matches('/'))) + .bearer_auth(&self.access_token) .query(&[("name", name)]) .send() .await diff --git a/sysclient/src/main.rs b/sysclient/src/main.rs index 6e33dcf0..82caf99c 100644 --- a/sysclient/src/main.rs +++ b/sysclient/src/main.rs @@ -2,7 +2,7 @@ //! //! This example demonstrates how to use the Sysinspect client to authenticate a user. //! It prompts the user for their username and password, then attempts to authenticate with the Sysinspect -//! server. If successful, it prints the session ID; otherwise, it indicates that authentication failed. +//! server. If successful, it prints the bearer token; otherwise, it indicates that authentication failed. //! use libcommon::SysinspectError; @@ -29,8 +29,8 @@ async fn main() -> Result<(), SysinspectError> { let mut client = SysClient::new(cfg); match client.authenticate(&uid, &pwd).await { - Ok(sid) => { - println!("Authentication successful, session ID: {sid}"); + Ok(token) => { + println!("Authentication successful, bearer token: {token}"); } Err(e) => { return Err(SysinspectError::MasterGeneralError(format!("Authentication error: {e}"))); From 1f2a128cb0f9e1dd082d5945198652cd47715f06 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 15:20:09 +0100 Subject: [PATCH 14/43] Introduce protocol version negotiation via ephemeral key --- .../src/transport/secure_bootstrap.rs | 274 +++++++++++++----- libsysproto/src/README.md | 10 +- libsysproto/src/secure.rs | 14 +- sysmaster/src/transport.rs | 26 +- 4 files changed, 239 insertions(+), 85 deletions(-) diff --git a/libsysinspect/src/transport/secure_bootstrap.rs b/libsysinspect/src/transport/secure_bootstrap.rs index e625ec74..123415a0 100644 --- a/libsysinspect/src/transport/secure_bootstrap.rs +++ b/libsysinspect/src/transport/secure_bootstrap.rs @@ -3,16 +3,19 @@ use base64::{Engine, engine::general_purpose::STANDARD}; use chrono::Utc; use libcommon::SysinspectError; use libsysproto::secure::{ - SECURE_PROTOCOL_VERSION, SecureBootstrapAck, SecureBootstrapDiagnostic, SecureBootstrapHello, SecureDiagnosticCode, SecureFailureSemantics, - SecureFrame, SecureRotationMode, SecureSessionBinding, + SECURE_SUPPORTED_PROTOCOL_VERSIONS, SecureBootstrapAck, SecureBootstrapDiagnostic, SecureBootstrapHello, SecureDiagnosticCode, + SecureFailureSemantics, SecureFrame, SecureRotationMode, SecureSessionBinding, }; use rsa::{RsaPrivateKey, RsaPublicKey}; use sha2::{Digest, Sha256}; -use sodiumoxide::crypto::secretbox::{self, Key}; +use sodiumoxide::crypto::{ + box_, + secretbox::{self, Key}, +}; use std::sync::OnceLock; use uuid::Uuid; -use crate::rsa::keys::{decrypt, encrypt, get_fingerprint, sign_data, verify_sign}; +use crate::rsa::keys::{get_fingerprint, sign_data, verify_sign}; static SODIUM_INIT: OnceLock<()> = OnceLock::new(); @@ -20,9 +23,21 @@ static SODIUM_INIT: OnceLock<()> = OnceLock::new(); #[derive(Debug, Clone)] pub struct SecureBootstrapSession { binding: SecureSessionBinding, - session_key: Key, + session_key: Option, key_id: String, session_id: Option, + offered_versions: Vec, + local_ephemeral_public: box_::PublicKey, + local_ephemeral_secret: Option, +} + +struct MasterBootstrapAckParams { + session_key: Key, + key_id: String, + session_id: String, + rotation: SecureRotationMode, + master_ephemeral_public: box_::PublicKey, + master_ephemeral_secret: box_::SecretKey, } /// Factory for the plaintext bootstrap diagnostics allowed before a secure session exists. @@ -34,6 +49,7 @@ impl SecureBootstrapSession { sodium_ready()?; Self::ready(state)?; Self::fingerprint("master", master_pbk, &state.master_rsa_fingerprint)?; + let offered_versions = SECURE_SUPPORTED_PROTOCOL_VERSIONS.to_vec(); let key_id = state.active_key_id.clone().or_else(|| state.last_key_id.clone()).unwrap_or_else(|| Uuid::new_v4().to_string()); let binding = SecureSessionBinding::bootstrap_opening( state.minion_id.clone(), @@ -43,13 +59,7 @@ impl SecureBootstrapSession { Uuid::new_v4().to_string(), Utc::now().timestamp(), ); - Self::hello( - binding.clone(), - state.key_material(&key_id).as_deref().map(|material| Self::derive_session_key(material, &binding)).unwrap_or_else(secretbox::gen_key), - key_id, - minion_prk, - master_pbk, - ) + Self::hello(binding.clone(), offered_versions, key_id, minion_prk, master_pbk) } /// Validate a bootstrap hello on the master side and return a signed acknowledgement frame. @@ -58,22 +68,38 @@ impl SecureBootstrapSession { key_id: Option, rotation: Option, ) -> Result<(Self, SecureFrame), SysinspectError> { Self::ready(state)?; - Self::opening(state, &hello.binding)?; + let negotiated_version = Self::negotiate_version(hello)?; + Self::opening(state, &hello.binding, &hello.supported_versions)?; Self::fingerprint("minion", minion_pbk, &state.minion_rsa_fingerprint)?; Self::fingerprint("master", &RsaPublicKey::from(master_prk), &state.master_rsa_fingerprint)?; + Self::verify_hello(hello, minion_pbk)?; + let mut accepted_binding = hello.binding.clone(); + accepted_binding.protocol_version = negotiated_version; + let minion_ephemeral_public = Self::ephemeral_public_key(&hello.client_ephemeral_pubkey)?; + let (master_ephemeral_public, master_ephemeral_secret) = box_::gen_keypair(); + let session_key = Self::derive_session_key( + &accepted_binding, + &minion_ephemeral_public, + &master_ephemeral_public, + &box_::precompute(&minion_ephemeral_public, &master_ephemeral_secret), + ); Self::ack( - hello.binding.clone(), - Self::verify_hello(hello, minion_pbk, master_prk)?, - hello - .key_id - .clone() - .or_else(|| key_id.clone()) - .or_else(|| state.active_key_id.clone()) - .or_else(|| state.last_key_id.clone()) - .unwrap_or_else(|| Uuid::new_v4().to_string()), + accepted_binding, master_prk, - session_id.unwrap_or_else(|| Uuid::new_v4().to_string()), - rotation.unwrap_or(SecureRotationMode::None), + MasterBootstrapAckParams { + session_key, + key_id: hello + .key_id + .clone() + .or_else(|| key_id.clone()) + .or_else(|| state.active_key_id.clone()) + .or_else(|| state.last_key_id.clone()) + .unwrap_or_else(|| Uuid::new_v4().to_string()), + session_id: session_id.unwrap_or_else(|| Uuid::new_v4().to_string()), + rotation: rotation.unwrap_or(SecureRotationMode::None), + master_ephemeral_public, + master_ephemeral_secret, + }, ) } @@ -88,9 +114,16 @@ impl SecureBootstrapSession { if ack.key_id.trim().is_empty() { return Err(SysinspectError::ProtoError("Secure bootstrap ack has an empty key id".to_string())); } + if !self.offered_versions.contains(&ack.binding.protocol_version) || !SECURE_SUPPORTED_PROTOCOL_VERSIONS.contains(&ack.binding.protocol_version) { + return Err(SysinspectError::ProtoError(format!( + "Negotiated secure bootstrap version {} is not supported by this minion", + ack.binding.protocol_version + ))); + } + let master_ephemeral_public = Self::ephemeral_public_key(&ack.master_ephemeral_pubkey)?; if !verify_sign( master_pbk, - &Self::ack_material(&ack.binding, &ack.session_id, &ack.key_id, &ack.rotation)?, + &Self::ack_material(&ack.binding, &ack.session_id, &ack.key_id, &ack.rotation, &ack.master_ephemeral_pubkey)?, STANDARD .decode(&ack.binding_signature) .map_err(|err| SysinspectError::SerializationError(format!("Failed to decode secure bootstrap ack signature: {err}")))?, @@ -102,6 +135,18 @@ impl SecureBootstrapSession { self.binding = ack.binding.clone(); self.key_id = ack.key_id.clone(); self.session_id = Some(ack.session_id.clone()); + self.session_key = Some(Self::derive_session_key( + &self.binding, + &self.local_ephemeral_public, + &master_ephemeral_public, + &box_::precompute( + &master_ephemeral_public, + self.local_ephemeral_secret + .as_ref() + .ok_or_else(|| SysinspectError::ProtoError("Secure bootstrap opening is missing the local ephemeral secret key".to_string()))?, + ), + )); + self.local_ephemeral_secret = None; Ok(self) } @@ -122,26 +167,35 @@ impl SecureBootstrapSession { /// Return the negotiated libsodium session key for the secure channel. pub fn session_key(&self) -> &Key { - &self.session_key + self.session_key + .as_ref() + .expect("secure bootstrap session key is only available after the bootstrap exchange completes") } /// Encode the minion's opening bootstrap frame and keep the local bootstrap state. fn hello( - binding: SecureSessionBinding, session_key: Key, key_id: String, minion_prk: &RsaPrivateKey, master_pbk: &RsaPublicKey, + binding: SecureSessionBinding, offered_versions: Vec, key_id: String, minion_prk: &RsaPrivateKey, _master_pbk: &RsaPublicKey, ) -> Result<(Self, SecureFrame), SysinspectError> { - let session_key_cipher = STANDARD.encode( - encrypt(master_pbk.clone(), session_key.0.to_vec()) - .map_err(|_| SysinspectError::RSAError("Failed to encrypt secure session key for the master".to_string()))?, - ); + let (client_ephemeral_public, client_ephemeral_secret) = box_::gen_keypair(); + let client_ephemeral_pubkey = STANDARD.encode(client_ephemeral_public.0); let binding_signature = STANDARD.encode( - sign_data(minion_prk.clone(), &Self::hello_material(&binding, &session_key_cipher, Some(&key_id))?) + sign_data(minion_prk.clone(), &Self::hello_material(&binding, &offered_versions, &client_ephemeral_pubkey, Some(&key_id))?) .map_err(|_| SysinspectError::RSAError("Failed to sign secure bootstrap binding".to_string()))?, ); Ok(( - Self { binding: binding.clone(), session_key: session_key.clone(), key_id: key_id.clone(), session_id: None }, + Self { + binding: binding.clone(), + session_key: None, + key_id: key_id.clone(), + session_id: None, + offered_versions: offered_versions.clone(), + local_ephemeral_public: client_ephemeral_public, + local_ephemeral_secret: Some(client_ephemeral_secret), + }, SecureFrame::BootstrapHello(SecureBootstrapHello { binding: binding.clone(), - session_key_cipher, + supported_versions: offered_versions, + client_ephemeral_pubkey, binding_signature, key_id: Some(key_id), }), @@ -150,26 +204,41 @@ impl SecureBootstrapSession { /// Encode the master's bootstrap acknowledgement after the hello was authenticated successfully. fn ack( - mut binding: SecureSessionBinding, session_key: Key, key_id: String, master_prk: &RsaPrivateKey, session_id: String, - rotation: SecureRotationMode, + mut binding: SecureSessionBinding, master_prk: &RsaPrivateKey, params: MasterBootstrapAckParams, ) -> Result<(Self, SecureFrame), SysinspectError> { binding.master_nonce = Uuid::new_v4().to_string(); + let master_ephemeral_pubkey = STANDARD.encode(params.master_ephemeral_public.0); let binding_signature = STANDARD.encode( - sign_data(master_prk.clone(), &Self::ack_material(&binding, &session_id, &key_id, &rotation)?) + sign_data(master_prk.clone(), &Self::ack_material(&binding, ¶ms.session_id, ¶ms.key_id, ¶ms.rotation, &master_ephemeral_pubkey)?) .map_err(|_| SysinspectError::RSAError("Failed to sign secure bootstrap acknowledgement".to_string()))?, ); Ok(( - Self { binding: binding.clone(), session_key, key_id: key_id.clone(), session_id: Some(session_id.clone()) }, - SecureFrame::BootstrapAck(SecureBootstrapAck { binding, session_id: session_id.clone(), key_id, rotation, binding_signature }), + Self { + binding: binding.clone(), + session_key: Some(params.session_key), + key_id: params.key_id.clone(), + session_id: Some(params.session_id.clone()), + offered_versions: vec![binding.protocol_version], + local_ephemeral_public: params.master_ephemeral_public, + local_ephemeral_secret: Some(params.master_ephemeral_secret), + }, + SecureFrame::BootstrapAck(SecureBootstrapAck { + binding, + session_id: params.session_id.clone(), + key_id: params.key_id, + rotation: params.rotation, + master_ephemeral_pubkey, + binding_signature, + }), )) } /// Check that the stored peer state is approved and matches the active protocol version. fn ready(state: &TransportPeerState) -> Result<(), SysinspectError> { - if state.protocol_version != SECURE_PROTOCOL_VERSION { + if !SECURE_SUPPORTED_PROTOCOL_VERSIONS.contains(&state.protocol_version) { return Err(SysinspectError::ProtoError(format!( - "Secure transport version mismatch in state: expected {}, found {}", - SECURE_PROTOCOL_VERSION, state.protocol_version + "Secure transport version {} in state is not supported locally", + state.protocol_version ))); } if state.approved_at.is_none() { @@ -179,9 +248,15 @@ impl SecureBootstrapSession { } /// Verify that an opening bootstrap binding matches the trusted peer state before accepting it. - fn opening(state: &TransportPeerState, binding: &SecureSessionBinding) -> Result<(), SysinspectError> { - if binding.protocol_version != SECURE_PROTOCOL_VERSION { - return Err(SysinspectError::ProtoError(format!("Unsupported secure bootstrap version {}", binding.protocol_version))); + fn opening(state: &TransportPeerState, binding: &SecureSessionBinding, supported_versions: &[u16]) -> Result<(), SysinspectError> { + if supported_versions.is_empty() { + return Err(SysinspectError::ProtoError("Secure bootstrap hello is missing supported protocol versions".to_string())); + } + if !supported_versions.contains(&binding.protocol_version) { + return Err(SysinspectError::ProtoError(format!( + "Secure bootstrap hello preferred version {} is not present in supported_versions", + binding.protocol_version + ))); } if binding.master_nonce.is_empty() && binding.minion_id == state.minion_id @@ -197,7 +272,7 @@ impl SecureBootstrapSession { /// Verify that an acknowledgement binding is still tied to the same handshake attempt and peer identities. fn accepted(state: &TransportPeerState, opening: &SecureSessionBinding, binding: &SecureSessionBinding) -> Result<(), SysinspectError> { - if binding.protocol_version != SECURE_PROTOCOL_VERSION { + if !SECURE_SUPPORTED_PROTOCOL_VERSIONS.contains(&binding.protocol_version) { return Err(SysinspectError::ProtoError(format!("Unsupported secure bootstrap ack version {}", binding.protocol_version))); } if binding.master_nonce.trim().is_empty() { @@ -214,11 +289,11 @@ impl SecureBootstrapSession { Err(SysinspectError::ProtoError("Secure bootstrap ack does not match the opening handshake binding".to_string())) } - /// Decrypt and authenticate the opening bootstrap frame sent by the minion. - fn verify_hello(hello: &SecureBootstrapHello, minion_pbk: &RsaPublicKey, master_prk: &RsaPrivateKey) -> Result { + /// Authenticate the opening bootstrap frame sent by the minion. + fn verify_hello(hello: &SecureBootstrapHello, minion_pbk: &RsaPublicKey) -> Result<(), SysinspectError> { if !verify_sign( minion_pbk, - &Self::hello_material(&hello.binding, &hello.session_key_cipher, hello.key_id.as_deref())?, + &Self::hello_material(&hello.binding, &hello.supported_versions, &hello.client_ephemeral_pubkey, hello.key_id.as_deref())?, STANDARD .decode(&hello.binding_signature) .map_err(|err| SysinspectError::SerializationError(format!("Failed to decode secure bootstrap signature: {err}")))?, @@ -227,61 +302,82 @@ impl SecureBootstrapSession { { return Err(SysinspectError::RSAError("Secure bootstrap hello signature verification failed".to_string())); } - let session_key = Self::key(&hello.session_key_cipher, master_prk)?; - Ok(session_key) + Ok(()) } - /// Decrypt the RSA-wrapped libsodium session key from the opening bootstrap frame. - fn key(cipher: &str, master_prk: &RsaPrivateKey) -> Result { - Key::from_slice( - &decrypt( - master_prk.clone(), - STANDARD - .decode(cipher) - .map_err(|err| SysinspectError::SerializationError(format!("Failed to decode secure bootstrap session key: {err}")))?, - ) - .map_err(|_| SysinspectError::RSAError("Failed to decrypt secure bootstrap session key".to_string()))?, + fn ephemeral_public_key(encoded: &str) -> Result { + box_::PublicKey::from_slice( + &STANDARD + .decode(encoded) + .map_err(|err| SysinspectError::SerializationError(format!("Failed to decode secure bootstrap ephemeral public key: {err}")))?, ) - .ok_or_else(|| SysinspectError::RSAError("Secure bootstrap session key has invalid size".to_string())) + .ok_or_else(|| SysinspectError::ProtoError("Secure bootstrap ephemeral public key has invalid size".to_string())) } - /// Derive a fresh bootstrap session key from persisted transport material and the opening handshake tuple. - /// Derive a per-bootstrap session key from persisted transport material and the unique opening binding. - fn derive_session_key(material: &[u8], binding: &SecureSessionBinding) -> Key { + /// Derive a per-bootstrap session key from the authenticated ephemeral key exchange and the handshake binding. + fn derive_session_key( + binding: &SecureSessionBinding, client_ephemeral_public: &box_::PublicKey, master_ephemeral_public: &box_::PublicKey, + shared: &box_::PrecomputedKey, + ) -> Key { let mut digest = Sha256::new(); - digest.update(b"sysinspect-secure-bootstrap"); - digest.update(material); + digest.update(b"sysinspect-secure-bootstrap-pfs"); + digest.update(shared.0); digest.update(binding.minion_id.as_bytes()); digest.update(binding.minion_rsa_fingerprint.as_bytes()); digest.update(binding.master_rsa_fingerprint.as_bytes()); digest.update(binding.connection_id.as_bytes()); digest.update(binding.client_nonce.as_bytes()); + digest.update(binding.master_nonce.as_bytes()); + digest.update(client_ephemeral_public.0); + digest.update(master_ephemeral_public.0); + digest.update(binding.timestamp.to_be_bytes()); digest.update(binding.protocol_version.to_be_bytes()); Key::from_slice(&digest.finalize()).unwrap_or_else(secretbox::gen_key) } - /// Build the signed material for a bootstrap hello from the binding, ciphered session key bytes and negotiated key id. - fn hello_material(binding: &SecureSessionBinding, session_key_cipher: &str, key_id: Option<&str>) -> Result, SysinspectError> { - Self::material(binding, Some(session_key_cipher.as_bytes()), key_id, None, None) + /// Build the signed material for a bootstrap hello from the binding, client ephemeral key and negotiated key id. + fn hello_material( + binding: &SecureSessionBinding, supported_versions: &[u16], client_ephemeral_pubkey: &str, key_id: Option<&str>, + ) -> Result, SysinspectError> { + Self::material( + binding, + Some(client_ephemeral_pubkey.as_bytes()), + Some(supported_versions), + key_id, + None, + None, + None, + ) } /// Build the signed material for a bootstrap acknowledgement from the binding, session id, activated key id and rotation state. fn ack_material( - binding: &SecureSessionBinding, session_id: &str, key_id: &str, rotation: &SecureRotationMode, + binding: &SecureSessionBinding, session_id: &str, key_id: &str, rotation: &SecureRotationMode, master_ephemeral_pubkey: &str, ) -> Result, SysinspectError> { - Self::material(binding, None, Some(key_id), Some(session_id), Some(rotation)) + Self::material( + binding, + None, + None, + Some(key_id), + Some(session_id), + Some(rotation), + Some(master_ephemeral_pubkey.as_bytes()), + ) } /// Serialize the binding and append the extra authenticated bootstrap material used for signatures. fn material( - binding: &SecureSessionBinding, session_key: Option<&[u8]>, key_id: Option<&str>, session_id: Option<&str>, - rotation: Option<&SecureRotationMode>, + binding: &SecureSessionBinding, ephemeral_key: Option<&[u8]>, supported_versions: Option<&[u16]>, key_id: Option<&str>, + session_id: Option<&str>, rotation: Option<&SecureRotationMode>, peer_ephemeral_key: Option<&[u8]>, ) -> Result, SysinspectError> { serde_json::to_vec(binding) .map(|mut out| { - if let Some(chunk) = session_key { + if let Some(chunk) = ephemeral_key { out.extend_from_slice(chunk); } + if let Some(chunk) = supported_versions { + out.extend_from_slice(serde_json::to_string(chunk).unwrap_or_default().as_bytes()); + } if let Some(chunk) = key_id { out.extend_from_slice(chunk.as_bytes()); } @@ -291,11 +387,39 @@ impl SecureBootstrapSession { if let Some(chunk) = rotation { out.extend_from_slice(format!("{chunk:?}").as_bytes()); } + if let Some(chunk) = peer_ephemeral_key { + out.extend_from_slice(chunk); + } out }) .map_err(|err| SysinspectError::SerializationError(format!("Failed to serialise secure bootstrap material: {err}"))) } + fn negotiate_version(hello: &SecureBootstrapHello) -> Result { + if hello.supported_versions.is_empty() { + return Err(SysinspectError::ProtoError("Secure bootstrap hello did not advertise any supported protocol versions".to_string())); + } + let common: Vec = hello + .supported_versions + .iter() + .copied() + .filter(|version| SECURE_SUPPORTED_PROTOCOL_VERSIONS.contains(version)) + .collect(); + if common.is_empty() { + return Err(SysinspectError::ProtoError(format!( + "No common secure transport protocol version exists between peer {:?} and local {:?}", + hello.supported_versions, SECURE_SUPPORTED_PROTOCOL_VERSIONS + ))); + } + if common.contains(&hello.binding.protocol_version) { + return Ok(hello.binding.protocol_version); + } + common + .into_iter() + .max() + .ok_or_else(|| SysinspectError::ProtoError("Secure bootstrap version negotiation failed".to_string())) + } + /// Verify that the presented RSA public key fingerprint matches the trusted transport state. fn fingerprint(label: &str, pbk: &RsaPublicKey, expected: &str) -> Result<(), SysinspectError> { if get_fingerprint(pbk).map_err(|err| SysinspectError::RSAError(err.to_string()))? == expected { diff --git a/libsysproto/src/README.md b/libsysproto/src/README.md index 3317f449..1e55a737 100644 --- a/libsysproto/src/README.md +++ b/libsysproto/src/README.md @@ -60,8 +60,9 @@ Sent by the minion to begin a new secure session. Fields: - `binding`: initial `SecureSessionBinding` -- `session_key_cipher`: fresh symmetric session key encrypted to the master's registered RSA key -- `binding_signature`: minion RSA signature over the binding and raw session key +- `supported_versions`: secure transport protocol versions supported by the minion +- `client_ephemeral_pubkey`: minion ephemeral Curve25519 public key +- `binding_signature`: minion RSA signature over the binding and ephemeral public key - `key_id`: optional transport key identifier for reconnect or rotation continuity #### `bootstrap_ack` @@ -74,7 +75,8 @@ Fields: - `session_id`: master-assigned secure session id - `key_id`: accepted transport key id - `rotation`: `none`, `rekey`, or `reregister` -- `binding_signature`: master RSA signature over the completed binding and accepted session id +- `master_ephemeral_pubkey`: master ephemeral Curve25519 public key +- `binding_signature`: master RSA signature over the completed binding, accepted session id, and master ephemeral public key #### `bootstrap_diagnostic` @@ -121,7 +123,7 @@ Rules: - only one active secure session may exist per minion - reconnects must create a new connection id and fresh nonces - replay protection is per direction and tied to the session id and active key id -- RSA remains only the bootstrap and rotation trust anchor +- RSA authenticates the ephemeral bootstrap exchange and remains the rotation trust anchor - steady-state traffic uses libsodium-protected frames only ## Message Structure diff --git a/libsysproto/src/secure.rs b/libsysproto/src/secure.rs index 96c348e1..9d8d9c57 100644 --- a/libsysproto/src/secure.rs +++ b/libsysproto/src/secure.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; /// Version of the planned secure Master/Minion transport protocol. pub const SECURE_PROTOCOL_VERSION: u16 = 1; +/// Explicit set of secure transport protocol versions currently supported locally. +pub const SECURE_SUPPORTED_PROTOCOL_VERSIONS: &[u16] = &[SECURE_PROTOCOL_VERSION]; /// Master/Minion transport goals fixed by Phase 1 decisions. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -127,9 +129,11 @@ impl SecureFailureSemantics { pub struct SecureBootstrapHello { /// Session binding input that must later be authenticated and echoed. pub binding: SecureSessionBinding, - /// Fresh symmetric session key encrypted to the master's registered RSA key. - pub session_key_cipher: String, - /// RSA signature over the bootstrap binding, wrapped session key ciphertext and negotiated key id. + /// Ordered secure transport protocol versions supported by the opening peer. + pub supported_versions: Vec, + /// Minion ephemeral Curve25519 public key for the bootstrap key exchange. + pub client_ephemeral_pubkey: String, + /// RSA signature over the bootstrap binding, ephemeral public key and negotiated key id. pub binding_signature: String, /// Optional transport key identifier when reconnecting or rotating. pub key_id: Option, @@ -146,7 +150,9 @@ pub struct SecureBootstrapAck { pub key_id: String, /// Rotation state communicated during handshake. pub rotation: SecureRotationMode, - /// RSA signature over the completed binding, accepted session identifier, activated key id and rotation state. + /// Master ephemeral Curve25519 public key for the bootstrap key exchange. + pub master_ephemeral_pubkey: String, + /// RSA signature over the completed binding, accepted session identifier, activated key id, rotation state and master ephemeral public key. pub binding_signature: String, } diff --git a/sysmaster/src/transport.rs b/sysmaster/src/transport.rs index dfeedbf5..5c5475cc 100644 --- a/sysmaster/src/transport.rs +++ b/sysmaster/src/transport.rs @@ -13,7 +13,7 @@ use libsysinspect::{ use libsysproto::{ MasterMessage, MinionMessage, ProtoConversion, rqtypes::RequestType, - secure::{SecureBootstrapHello, SecureFrame, SecureSessionBinding}, + secure::{SECURE_SUPPORTED_PROTOCOL_VERSIONS, SecureBootstrapHello, SecureFrame, SecureSessionBinding}, }; use rsa::RsaPublicKey; use std::{ @@ -147,7 +147,16 @@ impl PeerTransport { if let Some(diag) = Self::plaintext_diag(raw) { return Ok(IncomingFrame::Reply(diag)); } - Ok(IncomingFrame::Forward(raw.to_vec())) + match serde_json::from_slice::(raw) { + Ok(req) if matches!(req.req_type(), RequestType::Add) => Ok(IncomingFrame::Forward(raw.to_vec())), + Ok(_) => Ok(IncomingFrame::Reply( + serde_json::to_vec(&SecureBootstrapDiagnostics::unsupported_version( + "Plaintext minion traffic is not allowed; secure bootstrap is required", + )) + .map_err(SysinspectError::from)?, + )), + Err(_) => Err(SysinspectError::ProtoError("Unsupported pre-bootstrap peer traffic".to_string())), + } } /// Accept a bootstrap hello from a registered minion and store the resulting session for that peer. @@ -155,6 +164,19 @@ impl PeerTransport { &mut self, peer_addr: &str, hello: &SecureBootstrapHello, cfg: &MasterConfig, mkr: &mut MinionsKeyRegistry, ) -> Result, SysinspectError> { let now = Instant::now(); + if hello.supported_versions.is_empty() { + return serde_json::to_vec(&SecureBootstrapDiagnostics::unsupported_version( + "Secure bootstrap hello did not advertise any supported protocol versions", + )) + .map_err(SysinspectError::from); + } + if !hello.supported_versions.iter().any(|version| SECURE_SUPPORTED_PROTOCOL_VERSIONS.contains(version)) { + return serde_json::to_vec(&SecureBootstrapDiagnostics::unsupported_version(format!( + "No common secure transport protocol version exists between peer {:?} and local {:?}", + hello.supported_versions, SECURE_SUPPORTED_PROTOCOL_VERSIONS + ))) + .map_err(SysinspectError::from); + } if self.peers.iter().any(|(addr, peer)| addr != peer_addr && peer.minion_id == hello.binding.minion_id) { log::warn!("Rejecting duplicate bootstrap for minion {} from {}", hello.binding.minion_id, peer_addr); return serde_json::to_vec(&SecureBootstrapDiagnostics::duplicate_session(format!( From 1aa0b04c1af2723005f5c59e737e9a76fc96df77 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 15:20:27 +0100 Subject: [PATCH 15/43] Add proto bootstrap UT --- .../src/transport/secure_bootstrap_ut.rs | 135 ++++++++++++++++-- 1 file changed, 126 insertions(+), 9 deletions(-) diff --git a/libsysinspect/src/transport/secure_bootstrap_ut.rs b/libsysinspect/src/transport/secure_bootstrap_ut.rs index e8684754..9ac07ca8 100644 --- a/libsysinspect/src/transport/secure_bootstrap_ut.rs +++ b/libsysinspect/src/transport/secure_bootstrap_ut.rs @@ -2,11 +2,11 @@ use super::{ TransportKeyExchangeModel, TransportPeerState, TransportProvisioningMode, TransportRotationStatus, secure_bootstrap::{SecureBootstrapDiagnostics, SecureBootstrapSession}, }; -use crate::rsa::keys::{get_fingerprint, keygen}; +use crate::rsa::keys::{get_fingerprint, keygen, sign_data}; +use base64::{Engine, engine::general_purpose::STANDARD}; use chrono::Utc; use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SecureDiagnosticCode, SecureFrame, SecureRotationMode}; use rsa::RsaPublicKey; -use sodiumoxide::crypto::secretbox; fn state(master_pbk: &RsaPublicKey, minion_pbk: &RsaPublicKey) -> TransportPeerState { TransportPeerState { @@ -130,16 +130,133 @@ fn tampered_ack_key_id_is_rejected() { } #[test] -fn persisted_material_derives_distinct_session_keys_for_distinct_openings() { - let (_, master_pbk) = keygen(2048).unwrap(); +fn distinct_openings_derive_distinct_session_keys() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); let (minion_prk, minion_pbk) = keygen(2048).unwrap(); - let mut state = state(&master_pbk, &minion_pbk); - let material = secretbox::gen_key(); - state.upsert_key_with_material("kid-1", super::TransportKeyStatus::Active, Some(&material.0)); + let state = state(&master_pbk, &minion_pbk); + + let (first_opening, first_hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let first_ack = match SecureBootstrapSession::accept( + &state, + match &first_hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + let first = first_opening.verify_ack(&state, &first_ack, &master_pbk).unwrap(); - let first = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap().0; - let second = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap().0; + let (second_opening, second_hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let second_ack = match SecureBootstrapSession::accept( + &state, + match &second_hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-2".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + let second = second_opening.verify_ack(&state, &second_ack, &master_pbk).unwrap(); assert_ne!(first.binding().connection_id, second.binding().connection_id); assert_ne!(first.session_key().0.to_vec(), second.session_key().0.to_vec()); } + +#[test] +fn bootstrap_negotiates_down_to_supported_version() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (opening, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + + let mut hello = match hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }; + hello.binding.protocol_version = 99; + hello.supported_versions = vec![99, SECURE_PROTOCOL_VERSION]; + hello.binding_signature = STANDARD + .encode( + sign_data( + minion_prk.clone(), + &{ + let mut material = serde_json::to_vec(&hello.binding).unwrap(); + material.extend_from_slice(hello.client_ephemeral_pubkey.as_bytes()); + material.extend_from_slice(serde_json::to_string(&hello.supported_versions).unwrap().as_bytes()); + material.extend_from_slice(hello.key_id.as_deref().unwrap_or_default().as_bytes()); + material + }, + ) + .unwrap(), + ); + + let ack = match SecureBootstrapSession::accept( + &state, + &hello, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + + assert_eq!(ack.binding.protocol_version, SECURE_PROTOCOL_VERSION); + assert_eq!(opening.verify_ack(&state, &ack, &master_pbk).unwrap().binding().protocol_version, SECURE_PROTOCOL_VERSION); +} + +#[test] +fn bootstrap_rejects_when_no_common_version_exists() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (_, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + + let mut hello = match hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }; + hello.binding.protocol_version = 99; + hello.supported_versions = vec![99]; + hello.binding_signature = STANDARD + .encode( + sign_data( + minion_prk.clone(), + &{ + let mut material = serde_json::to_vec(&hello.binding).unwrap(); + material.extend_from_slice(hello.client_ephemeral_pubkey.as_bytes()); + material.extend_from_slice(serde_json::to_string(&hello.supported_versions).unwrap().as_bytes()); + material.extend_from_slice(hello.key_id.as_deref().unwrap_or_default().as_bytes()); + material + }, + ) + .unwrap(), + ); + + let err = SecureBootstrapSession::accept(&state, &hello, &master_prk, &minion_pbk, None, Some("kid-1".to_string()), None).unwrap_err(); + assert!(err.to_string().contains("No common secure transport protocol version")); +} From e0266f89ea56667e721d912bc136729ac0a83cac Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 15:20:40 +0100 Subject: [PATCH 16/43] Adjust UTs --- libsysinspect/src/transport/secure_channel_ut.rs | 7 ++----- libsysproto/src/secure/secure_ut.rs | 11 +++++++---- sysmaster/src/master_ut.rs | 5 +++-- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/libsysinspect/src/transport/secure_channel_ut.rs b/libsysinspect/src/transport/secure_channel_ut.rs index 1631cd8c..e854b45e 100644 --- a/libsysinspect/src/transport/secure_channel_ut.rs +++ b/libsysinspect/src/transport/secure_channel_ut.rs @@ -7,7 +7,6 @@ use crate::rsa::keys::{get_fingerprint, keygen}; use chrono::Utc; use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SecureFrame}; use rsa::RsaPublicKey; -use sodiumoxide::crypto::secretbox; fn state(master_pbk: &RsaPublicKey, minion_pbk: &RsaPublicKey) -> TransportPeerState { TransportPeerState { @@ -105,12 +104,10 @@ fn secure_channel_rejects_oversized_payloads() { } #[test] -fn secure_channel_first_frame_differs_across_reconnects_with_same_persisted_material() { +fn secure_channel_first_frame_differs_across_reconnects() { let (master_prk, master_pbk) = keygen(2048).unwrap(); let (minion_prk, minion_pbk) = keygen(2048).unwrap(); - let mut state = state(&master_pbk, &minion_pbk); - let material = secretbox::gen_key(); - state.upsert_key_with_material("kid-1", super::TransportKeyStatus::Active, Some(&material.0)); + let state = state(&master_pbk, &minion_pbk); let (opening_one, hello_one) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); let ack_one = match SecureBootstrapSession::accept( diff --git a/libsysproto/src/secure/secure_ut.rs b/libsysproto/src/secure/secure_ut.rs index ea266033..35d1a1bb 100644 --- a/libsysproto/src/secure/secure_ut.rs +++ b/libsysproto/src/secure/secure_ut.rs @@ -1,6 +1,6 @@ use super::{ - SECURE_PROTOCOL_VERSION, SecureBootstrapAck, SecureBootstrapDiagnostic, SecureBootstrapHello, SecureDataFrame, SecureDiagnosticCode, - SecureFailureSemantics, SecureFrame, SecureRotationMode, SecureSessionBinding, SecureTransportGoals, + SECURE_PROTOCOL_VERSION, SECURE_SUPPORTED_PROTOCOL_VERSIONS, SecureBootstrapAck, SecureBootstrapDiagnostic, SecureBootstrapHello, SecureDataFrame, + SecureDiagnosticCode, SecureFailureSemantics, SecureFrame, SecureRotationMode, SecureSessionBinding, SecureTransportGoals, }; fn binding() -> SecureSessionBinding { @@ -39,7 +39,8 @@ fn only_bootstrap_frames_may_stay_plaintext() { assert!( SecureFrame::BootstrapHello(SecureBootstrapHello { binding: binding(), - session_key_cipher: "cipher".to_string(), + supported_versions: SECURE_SUPPORTED_PROTOCOL_VERSIONS.to_vec(), + client_ephemeral_pubkey: "pubkey".to_string(), binding_signature: "sig".to_string(), key_id: None, }) @@ -51,6 +52,7 @@ fn only_bootstrap_frames_may_stay_plaintext() { session_id: "sid".to_string(), key_id: "kid".to_string(), rotation: SecureRotationMode::None, + master_ephemeral_pubkey: "pubkey".to_string(), binding_signature: "sig".to_string(), }) .is_plaintext_bootstrap() @@ -104,7 +106,8 @@ fn secure_frame_serde_uses_stable_kind_tags() { assert_eq!( serde_json::to_value(SecureFrame::BootstrapHello(SecureBootstrapHello { binding: binding(), - session_key_cipher: "cipher".to_string(), + supported_versions: SECURE_SUPPORTED_PROTOCOL_VERSIONS.to_vec(), + client_ephemeral_pubkey: "pubkey".to_string(), binding_signature: "sig".to_string(), key_id: Some("kid".to_string()), })) diff --git a/sysmaster/src/master_ut.rs b/sysmaster/src/master_ut.rs index 46a3ce8a..b222f885 100644 --- a/sysmaster/src/master_ut.rs +++ b/sysmaster/src/master_ut.rs @@ -6,7 +6,7 @@ use libsysinspect::{ TransportKeyExchangeModel, TransportPeerState, TransportProvisioningMode, TransportRotationStatus, secure_bootstrap::SecureBootstrapSession, }, }; -use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SecureBootstrapHello, SecureFrame, SecureSessionBinding}; +use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SECURE_SUPPORTED_PROTOCOL_VERSIONS, SecureBootstrapHello, SecureFrame, SecureSessionBinding}; use rsa::RsaPublicKey; use std::{collections::HashMap, time::Instant}; @@ -47,7 +47,8 @@ fn unsupported_peer_bounces_secure_bootstrap_hello() { "nonce-1".to_string(), fresh_timestamp(), ), - session_key_cipher: "cipher".to_string(), + supported_versions: SECURE_SUPPORTED_PROTOCOL_VERSIONS.to_vec(), + client_ephemeral_pubkey: "pubkey".to_string(), binding_signature: "sig".to_string(), key_id: Some("kid-1".to_string()), })) From 7c85d485a489cf936a9c2b8892a48461b8365f8d Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 15:35:41 +0100 Subject: [PATCH 17/43] Bugfix: key no longer symmetric after nego (sometimes) --- .../src/transport/secure_bootstrap.rs | 18 ++++++------ libsysinspect/src/transport/secure_channel.rs | 29 ++++++------------- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/libsysinspect/src/transport/secure_bootstrap.rs b/libsysinspect/src/transport/secure_bootstrap.rs index 123415a0..d68e7a54 100644 --- a/libsysinspect/src/transport/secure_bootstrap.rs +++ b/libsysinspect/src/transport/secure_bootstrap.rs @@ -32,12 +32,12 @@ pub struct SecureBootstrapSession { } struct MasterBootstrapAckParams { - session_key: Key, key_id: String, session_id: String, rotation: SecureRotationMode, master_ephemeral_public: box_::PublicKey, master_ephemeral_secret: box_::SecretKey, + minion_ephemeral_public: box_::PublicKey, } /// Factory for the plaintext bootstrap diagnostics allowed before a secure session exists. @@ -77,17 +77,10 @@ impl SecureBootstrapSession { accepted_binding.protocol_version = negotiated_version; let minion_ephemeral_public = Self::ephemeral_public_key(&hello.client_ephemeral_pubkey)?; let (master_ephemeral_public, master_ephemeral_secret) = box_::gen_keypair(); - let session_key = Self::derive_session_key( - &accepted_binding, - &minion_ephemeral_public, - &master_ephemeral_public, - &box_::precompute(&minion_ephemeral_public, &master_ephemeral_secret), - ); Self::ack( accepted_binding, master_prk, MasterBootstrapAckParams { - session_key, key_id: hello .key_id .clone() @@ -99,6 +92,7 @@ impl SecureBootstrapSession { rotation: rotation.unwrap_or(SecureRotationMode::None), master_ephemeral_public, master_ephemeral_secret, + minion_ephemeral_public, }, ) } @@ -207,6 +201,12 @@ impl SecureBootstrapSession { mut binding: SecureSessionBinding, master_prk: &RsaPrivateKey, params: MasterBootstrapAckParams, ) -> Result<(Self, SecureFrame), SysinspectError> { binding.master_nonce = Uuid::new_v4().to_string(); + let session_key = Self::derive_session_key( + &binding, + ¶ms.minion_ephemeral_public, + ¶ms.master_ephemeral_public, + &box_::precompute(¶ms.minion_ephemeral_public, ¶ms.master_ephemeral_secret), + ); let master_ephemeral_pubkey = STANDARD.encode(params.master_ephemeral_public.0); let binding_signature = STANDARD.encode( sign_data(master_prk.clone(), &Self::ack_material(&binding, ¶ms.session_id, ¶ms.key_id, ¶ms.rotation, &master_ephemeral_pubkey)?) @@ -215,7 +215,7 @@ impl SecureBootstrapSession { Ok(( Self { binding: binding.clone(), - session_key: Some(params.session_key), + session_key: Some(session_key), key_id: params.key_id.clone(), session_id: Some(params.session_id.clone()), offered_versions: vec![binding.protocol_version], diff --git a/libsysinspect/src/transport/secure_channel.rs b/libsysinspect/src/transport/secure_channel.rs index 151e9004..3960c493 100644 --- a/libsysinspect/src/transport/secure_channel.rs +++ b/libsysinspect/src/transport/secure_channel.rs @@ -29,7 +29,6 @@ pub struct SecureChannel { session_id: String, key_id: String, key: Key, - role: SecurePeerRole, tx_counter: u64, rx_counter: u64, base_nonce: [u8; secretbox::NONCEBYTES], @@ -37,7 +36,7 @@ pub struct SecureChannel { impl SecureChannel { /// Create a steady-state secure channel from an accepted bootstrap session. - pub fn new(role: SecurePeerRole, bootstrap: &SecureBootstrapSession) -> Result { + pub fn new(_role: SecurePeerRole, bootstrap: &SecureBootstrapSession) -> Result { sodium_ready()?; let session_id = bootstrap .session_id() @@ -45,6 +44,9 @@ impl SecureChannel { .to_string(); let mut digest = Sha256::new(); + digest.update(bootstrap.binding().connection_id.as_bytes()); + digest.update(bootstrap.binding().client_nonce.as_bytes()); + digest.update(bootstrap.binding().master_nonce.as_bytes()); digest.update(session_id.as_bytes()); digest.update(bootstrap.session_key().0); let hash = digest.finalize(); @@ -55,7 +57,6 @@ impl SecureChannel { session_id, key_id: bootstrap.key_id().to_string(), key: bootstrap.session_key().clone(), - role, tx_counter: 0, rx_counter: 0, base_nonce, @@ -92,8 +93,8 @@ impl SecureChannel { session_id: self.session_id.clone(), key_id: self.key_id.clone(), counter: self.tx_counter, - nonce: STANDARD.encode(Self::nonce(self.role, self.tx_counter, &self.base_nonce).0), - payload: STANDARD.encode(secretbox::seal(payload, &Self::nonce(self.role, self.tx_counter, &self.base_nonce), &self.key)), + nonce: STANDARD.encode(Self::nonce(self.tx_counter, &self.base_nonce).0), + payload: STANDARD.encode(secretbox::seal(payload, &Self::nonce(self.tx_counter, &self.base_nonce), &self.key)), })) .map_err(|err| SysinspectError::SerializationError(format!("Failed to encode secure data frame: {err}"))) } @@ -144,7 +145,7 @@ impl SecureChannel { if frame.counter != self.rx_counter.saturating_add(1) { return Err(SysinspectError::ProtoError(format!("Secure frame counter {} is out of sequence after {}", frame.counter, self.rx_counter))); } - let expected_nonce = Self::nonce(Self::peer_role(self.role), frame.counter, &self.base_nonce); + let expected_nonce = Self::nonce(frame.counter, &self.base_nonce); if STANDARD.encode(expected_nonce.0) != frame.nonce { return Err(SysinspectError::ProtoError("Secure data frame nonce does not match the expected counter-derived nonce".to_string())); } @@ -161,27 +162,15 @@ impl SecureChannel { Ok(payload) } - /// Derive a deterministic nonce from the sender role, base_nonce and monotonic counter. - fn nonce(role: SecurePeerRole, counter: u64, base_nonce: &[u8; secretbox::NONCEBYTES]) -> Nonce { + /// Derive a deterministic nonce from the base_nonce and monotonic counter. + fn nonce(counter: u64, base_nonce: &[u8; secretbox::NONCEBYTES]) -> Nonce { let mut nonce = *base_nonce; - nonce[0] ^= match role { - SecurePeerRole::Master => 1, - SecurePeerRole::Minion => 2, - }; let counter_bytes = counter.to_be_bytes(); for i in 0..8 { nonce[secretbox::NONCEBYTES - 8 + i] ^= counter_bytes[i]; } Nonce(nonce) } - - /// Return the opposite role used to validate the sender side of an incoming frame. - fn peer_role(role: SecurePeerRole) -> SecurePeerRole { - match role { - SecurePeerRole::Master => SecurePeerRole::Minion, - SecurePeerRole::Minion => SecurePeerRole::Master, - } - } } fn sodium_ready() -> Result<(), SysinspectError> { From 86f1d76b2fc991a1f7dbdd7fc7efb3cfb8cfefae Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 15:36:00 +0100 Subject: [PATCH 18/43] Adjust UT --- .../src/transport/secure_channel_ut.rs | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/libsysinspect/src/transport/secure_channel_ut.rs b/libsysinspect/src/transport/secure_channel_ut.rs index e854b45e..4d275b35 100644 --- a/libsysinspect/src/transport/secure_channel_ut.rs +++ b/libsysinspect/src/transport/secure_channel_ut.rs @@ -32,7 +32,7 @@ fn channels() -> (SecureChannel, SecureChannel) { let (minion_prk, minion_pbk) = keygen(2048).unwrap(); let state = state(&master_pbk, &minion_pbk); let (opening, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); - let ack = match SecureBootstrapSession::accept( + let accepted = SecureBootstrapSession::accept( &state, match &hello { SecureFrame::BootstrapHello(hello) => hello, @@ -44,27 +44,13 @@ fn channels() -> (SecureChannel, SecureChannel) { Some("kid-1".to_string()), None, ) - .unwrap() - .1 - { + .unwrap(); + let ack = match accepted.1 { SecureFrame::BootstrapAck(ack) => ack, _ => panic!("expected bootstrap ack"), }; let minion = opening.verify_ack(&state, &ack, &master_pbk).unwrap(); - let master = SecureBootstrapSession::accept( - &state, - match &hello { - SecureFrame::BootstrapHello(hello) => hello, - _ => panic!("expected bootstrap hello"), - }, - &master_prk, - &minion_pbk, - Some("sid-1".to_string()), - Some("kid-1".to_string()), - None, - ) - .unwrap() - .0; + let master = accepted.0; (SecureChannel::new(SecurePeerRole::Master, &master).unwrap(), SecureChannel::new(SecurePeerRole::Minion, &minion).unwrap()) } From 7ae993782287a2b100f38c14ba3eeefc8dd88225 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 15:43:56 +0100 Subject: [PATCH 19/43] Update docs for transport and config --- docs/genusage/secure_transport.rst | 66 ++++++++++++++++++++++++++++++ docs/global_config.rst | 14 ++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/docs/genusage/secure_transport.rst b/docs/genusage/secure_transport.rst index a9964dca..9d20e48e 100644 --- a/docs/genusage/secure_transport.rst +++ b/docs/genusage/secure_transport.rst @@ -124,6 +124,36 @@ The protection happens in two steps: So the long-term trust comes from the registered identities, while everyday traffic is protected by a fresh session created when the connection starts. +What Changes And What Does Not +------------------------------ + +This transport change affects the Master/Minion boundary only. + +What changes: + +- the Master/Minion bootstrap now uses authenticated ephemeral key exchange +- reconnects always create a fresh secure session +- unsupported or malformed peers fail explicitly instead of falling back + +What does not change: + +- the local ``sysinspect`` to ``sysmaster`` console path still uses its own + local console transport +- the embedded Web API still uses normal HTTPS/TLS and is separate from the + Master/Minion transport +- the fileserver still publishes artefacts on its existing fileserver endpoint +- profile assignment still happens through master-managed traits and normal + sync workflows + +Operationally this means: + +- console administration commands keep working as before +- Web API TLS settings are configured separately under ``api.*`` +- ``sysinspect --sync`` still refreshes modules, libraries, sensors, and + profiles through the fileserver path after the secure control channel is up +- profile sync policy is unchanged; the secure transport only protects the + control messages that trigger or coordinate it + What Operators Should Do ------------------------ @@ -151,6 +181,29 @@ If a Minion can no longer establish a secure connection, the usual causes are: In those cases, prefer the supported recovery path such as re-registration or re-bootstrap instead of editing transport files manually. +Operator Diagnostics +-------------------- + +Sysinspect now emits operator-visible diagnostics for the common failure cases. + +Look for these classes of messages: + +- secure bootstrap authentication failure +- secure bootstrap replay rejection +- secure bootstrap version mismatch or malformed-frame rejection +- staged rotation key mismatch versus the reconnecting Minion key +- Web API TLS startup failure, including configured cert/key/CA paths + +The quickest operator checks are: + +- ``sysinspect network --status`` for active key id, last handshake time, and + rotation state +- the master error log for bootstrap rejection and TLS startup messages +- the minion error log for bootstrap-diagnostic and ack-verification failures + +If a Minion reconnects but does not complete bootstrap, check the logs on both +sides before editing any managed state. + Transport Rotation Workflow --------------------------- @@ -385,6 +438,19 @@ The status view includes: - current rotation state - ``security.transport.last-rotated-at`` value +Fresh Installs, Re-Registration, And Admin Workflows +---------------------------------------------------- + +The intended operator workflow remains simple: + +- fresh registration auto-provisions the managed transport metadata +- normal reconnects auto-bootstrap a fresh secure session +- re-registration replaces the trust relationship when identity changes +- master-side administration stays on the console and Web API paths + +In other words, the secure transport is hardened without adding a manual +day-to-day key exchange procedure for operators. + Rotation Safety Model --------------------- diff --git a/docs/global_config.rst b/docs/global_config.rst index e78695ff..1a8e2b2b 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -289,9 +289,12 @@ Below are directives for the configuration of the File Server service: .. important:: When enabled, the WebAPI serves its OpenAPI documentation through Swagger UI. - The documentation endpoint is available at ``http://:/doc/``. + The documentation endpoint is available at ``https://:/doc/``. - The Swagger UI is typically available at ``http://:/doc/``. + If ``api.enabled`` is ``true`` but TLS is not configured correctly, + ``sysmaster`` keeps running and the Web API stays disabled with an error log. + + The Swagger UI is typically available at ``https://:/doc/``. Default port is ``4202``. .. note:: @@ -548,6 +551,13 @@ Example configuration for the Sysinspect Master: - my_model - my_other_model + api.enabled: false + # To enable the embedded Web API, configure TLS first: + # api.enabled: true + # api.tls.enabled: true + # api.tls.cert-file: etc/web/api.crt + # api.tls.key-file: etc/web/api.key + ``datastore.path`` ################### Type: **string** From 8b832b538aa1a7e3d10e83961ed39e4dd22361a0 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 15:44:09 +0100 Subject: [PATCH 20/43] Update confing sample --- etc/sysinspect.conf.sample | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/etc/sysinspect.conf.sample b/etc/sysinspect.conf.sample index 93b3d5d3..506a8b98 100644 --- a/etc/sysinspect.conf.sample +++ b/etc/sysinspect.conf.sample @@ -113,9 +113,19 @@ config: pidfile: /tmp/sysinspect.pid # API configuration + # The embedded Web API only runs when TLS is configured. + # Leave it disabled until certificate and key paths are in place. + api.enabled: false + # api.enabled: true + # api.bind.ip: 0.0.0.0 + # api.bind.port: 4202 + # api.tls.enabled: true + # api.tls.cert-file: etc/web/api.crt + # api.tls.key-file: etc/web/api.key + # api.tls.ca-file: trust/ca.pem + # api.tls.allow-insecure: false # Set api.devmode only when you need auth bypass and the development query endpoint. api.devmode: false - api.enabled: true # Configuration that is present only on a minion node minion: From f26ab2f174bb0987dbd35bc39a2423ce10bc85d4 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 15:44:59 +0100 Subject: [PATCH 21/43] Update logging on transpoirt errors --- libwebapi/src/lib.rs | 10 +++++++++ sysmaster/src/transport.rs | 42 ++++++++++++++++++++++++++++++++------ sysminion/src/minion.rs | 16 +++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/libwebapi/src/lib.rs b/libwebapi/src/lib.rs index 2d8e3275..7533dc40 100644 --- a/libwebapi/src/lib.rs +++ b/libwebapi/src/lib.rs @@ -107,6 +107,15 @@ fn load_tls_server_config(cfg: &MasterConfig) -> Result String { + format!( + "cert={}, key={}, ca={}", + cfg.api_tls_cert_file().map(|p| p.display().to_string()).unwrap_or_else(|| "".to_string()), + cfg.api_tls_key_file().map(|p| p.display().to_string()).unwrap_or_else(|| "".to_string()), + cfg.api_tls_ca_file().map(|p| p.display().to_string()).unwrap_or_else(|| "".to_string()) + ) +} + /// Starts the embedded Web API server in a new thread, using the provided MasterConfig and MasterInterface. pub fn start_embedded_webapi(cfg: MasterConfig, master: MasterInterfaceType) -> Result<(), SysinspectError> { if !cfg.api_enabled() { @@ -124,6 +133,7 @@ pub fn start_embedded_webapi(cfg: MasterConfig, master: MasterInterfaceType) -> Err(err) => { log::error!("{}", tls_setup_err_message()); log::error!("Embedded Web API TLS setup error: {err}"); + log::error!("Embedded Web API TLS paths: {}", tls_paths_summary(&cfg)); return Ok(()); } }; diff --git a/sysmaster/src/transport.rs b/sysmaster/src/transport.rs index 5c5475cc..16be5858 100644 --- a/sysmaster/src/transport.rs +++ b/sysmaster/src/transport.rs @@ -132,8 +132,8 @@ impl PeerTransport { .ok_or_else(|| SysinspectError::ProtoError(format!("Peer transport state for {peer_addr} disappeared")))? .channel .open_bytes(raw); - if decoded.is_err() { - log::warn!("Session for peer {} became invalid; dropping channel state", peer_addr); + if let Err(err) = &decoded { + log::warn!("Session for peer {} became invalid: {}; dropping channel state", peer_addr, err); self.peers.remove(peer_addr); } return decoded.map(IncomingFrame::Forward); @@ -194,7 +194,7 @@ impl PeerTransport { .ok_or_else(|| SysinspectError::ProtoError(format!("No managed transport state exists for {}", hello.binding.minion_id)))?; let master_prk = mkr.master_private_key()?; let minion_pbk = mkr.minion_public_key(&hello.binding.minion_id)?; - let (bootstrap, ack) = SecureBootstrapSession::accept( + let (bootstrap, ack) = match SecureBootstrapSession::accept( &state, hello, &master_prk, @@ -202,17 +202,29 @@ impl PeerTransport { None, state.active_key_id.clone().or_else(|| state.last_key_id.clone()), None, - )?; + ) { + Ok(result) => result, + Err(err) => { + log::warn!( + "Secure bootstrap authentication failed for minion {} from {}: {}", + hello.binding.minion_id, + peer_addr, + err + ); + return Err(err); + } + }; Self::record_bootstrap_replay(&mut self.bootstrap_replay_cache, &hello.binding, now); Self::promote_bootstrap_key(cfg, mkr, &mut state, hello, bootstrap.key_id())?; TransportStore::for_master_minion(cfg, &hello.binding.minion_id)?.save(&state)?; log::info!( - "Session established for minion {} from {} using key {} and protocol v{}", + "Session established for minion {} from {} using key {} and protocol v{} (rotation state: {:?})", hello.binding.minion_id, peer_addr, bootstrap.key_id(), - hello.binding.protocol_version + hello.binding.protocol_version, + state.rotation ); self.peers.insert( peer_addr.to_string(), @@ -272,8 +284,26 @@ impl PeerTransport { let _ = rotator.retire_elapsed_keys(Utc::now(), overlap)?; *state = rotator.state().clone(); state.set_pending_rotation_context(None); + log::info!( + "Applied staged transport rotation for {} using key {} with {}s overlap", + hello.binding.minion_id, + bootstrap_key_id, + payload.grace_seconds + ); return Ok(()); } + if let Some(context) = state.pending_rotation_context.clone() + && let Ok(payload) = serde_json::from_str::(&context) + && payload.intent.intent().next_key_id() != bootstrap_key_id + { + log::warn!( + "Minion {} bootstrapped with key {} while staged rotation expects {}; leaving rotation state as {:?}", + hello.binding.minion_id, + bootstrap_key_id, + payload.intent.intent().next_key_id(), + state.rotation + ); + } state.upsert_key(bootstrap_key_id, libsysinspect::transport::TransportKeyStatus::Active); Ok(()) } diff --git a/sysminion/src/minion.rs b/sysminion/src/minion.rs index 5f28ccc3..44b3c477 100644 --- a/sysminion/src/minion.rs +++ b/sysminion/src/minion.rs @@ -376,6 +376,7 @@ impl SysMinion { let (opening, hello) = match SecureBootstrapSession::open(&state, &self.kman.private_key()?, &master_pbk) { Ok(opening) => opening, Err(err) => { + log::error!("Unable to prepare secure bootstrap for master {}: {}", self.cfg.master(), err); self.mark_broken_transport(&store, &mut state, None); return Err(err); } @@ -393,6 +394,12 @@ impl SysMinion { let session = match opening.verify_ack(&state, &ack, &master_pbk) { Ok(session) => session, Err(err) => { + log::error!( + "Secure bootstrap ack verification failed for master {} using key {}: {}", + self.cfg.master(), + opening_key_id, + err + ); self.mark_broken_transport(&store, &mut state, Some(&opening_key_id)); return Err(err); } @@ -408,10 +415,19 @@ impl SysMinion { Ok(()) } SecureFrame::BootstrapDiagnostic(diag) => { + log::error!( + "Master {} rejected secure bootstrap with {:?}: {} (retryable={}, rate_limit={})", + self.cfg.master(), + diag.code, + diag.message, + diag.failure.retryable, + diag.failure.rate_limit + ); self.mark_broken_transport(&store, &mut state, Some(&opening_key_id)); Err(SysinspectError::ProtoError(format!("Master rejected secure bootstrap with {:?}: {}", diag.code, diag.message))) } _ => { + log::error!("Master {} replied with a non-bootstrap frame during secure bootstrap", self.cfg.master()); self.mark_broken_transport(&store, &mut state, Some(&opening_key_id)); Err(SysinspectError::ProtoError("Master replied with a non-bootstrap frame during secure bootstrap".to_string())) } From 7effb33736307795afff82fe0214b7d867469508 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 16:03:21 +0100 Subject: [PATCH 22/43] Update dependencies --- Cargo.lock | 5 +++++ sysclient/Cargo.toml | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 11ffd76b..35e2716b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7774,13 +7774,18 @@ dependencies = [ name = "sysinspect-client" version = "0.1.0" dependencies = [ + "actix-web", + "async-trait", "libcommon", + "libdatastore", "libsysinspect", + "libwebapi", "log", "reqwest 0.12.28", "rpassword", "serde", "serde_json", + "tempfile", "tokio", ] diff --git a/sysclient/Cargo.toml b/sysclient/Cargo.toml index 77b7cb56..4d6cab2a 100644 --- a/sysclient/Cargo.toml +++ b/sysclient/Cargo.toml @@ -12,3 +12,10 @@ serde_json = "1.0.149" rpassword = "7.4.0" log = "0.4.29" reqwest = { version = "0.12.28", features = ["json"] } + +[dev-dependencies] +actix-web = "4.12.1" +async-trait = "0.1.89" +libwebapi = { path = "../libwebapi" } +libdatastore = { path = "../libdatastore" } +tempfile = "3.25.0" From 5d08a0c175106c950810150dc9b8af336a7c85f7 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 16:03:33 +0100 Subject: [PATCH 23/43] Add bootstrap UT --- .../src/transport/secure_bootstrap_ut.rs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/libsysinspect/src/transport/secure_bootstrap_ut.rs b/libsysinspect/src/transport/secure_bootstrap_ut.rs index 9ac07ca8..f431da4a 100644 --- a/libsysinspect/src/transport/secure_bootstrap_ut.rs +++ b/libsysinspect/src/transport/secure_bootstrap_ut.rs @@ -260,3 +260,101 @@ fn bootstrap_rejects_when_no_common_version_exists() { let err = SecureBootstrapSession::accept(&state, &hello, &master_prk, &minion_pbk, None, Some("kid-1".to_string()), None).unwrap_err(); assert!(err.to_string().contains("No common secure transport protocol version")); } + +#[test] +fn bootstrap_hello_roundtrips_through_json() { + let (_, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (_, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + + let parsed = serde_json::from_slice::(&serde_json::to_vec(&hello).unwrap()).unwrap(); + + assert!(matches!(parsed, SecureFrame::BootstrapHello(_))); +} + +#[test] +fn accept_rejects_wrong_registered_minion_key() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let (_, wrong_minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (_, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + + let err = SecureBootstrapSession::accept( + &state, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &wrong_minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap_err() + .to_string(); + + assert!(err.contains("fingerprint") || err.contains("signature")); +} + +#[test] +fn verify_ack_rejects_wrong_master_key() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (_, wrong_master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (opening, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let ack = match SecureBootstrapSession::accept( + &state, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + + let err = opening.verify_ack(&state, &ack, &wrong_master_pbk).unwrap_err().to_string(); + assert!(err.contains("fingerprint") || err.contains("signature")); +} + +#[test] +fn verify_ack_rejects_invalid_master_ephemeral_key() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (opening, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let mut ack = match SecureBootstrapSession::accept( + &state, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + ack.master_ephemeral_pubkey = STANDARD.encode([0u8; 8]); + + let err = opening.verify_ack(&state, &ack, &master_pbk).unwrap_err().to_string(); + assert!(err.contains("invalid size")); +} From 787a1121b9e34fdb9e2e07085b9d9af75394753f Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 16:03:43 +0100 Subject: [PATCH 24/43] Add secure channel UT --- .../src/transport/secure_channel_ut.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/libsysinspect/src/transport/secure_channel_ut.rs b/libsysinspect/src/transport/secure_channel_ut.rs index 4d275b35..49b382e8 100644 --- a/libsysinspect/src/transport/secure_channel_ut.rs +++ b/libsysinspect/src/transport/secure_channel_ut.rs @@ -142,3 +142,64 @@ fn secure_channel_first_frame_differs_across_reconnects() { assert_ne!(frame_one, frame_two); } + +#[test] +fn secure_channel_accepts_consecutive_frames() { + let (mut master, mut minion) = channels(); + let first = minion.seal(&serde_json::json!({"n":1})).unwrap(); + let second = minion.seal(&serde_json::json!({"n":2})).unwrap(); + + let first_payload: serde_json::Value = master.open(&first).unwrap(); + let second_payload: serde_json::Value = master.open(&second).unwrap(); + + assert_eq!(first_payload["n"], 1); + assert_eq!(second_payload["n"], 2); +} + +#[test] +fn secure_channel_rejects_truncated_frame_bytes() { + let (mut master, mut minion) = channels(); + let mut frame = minion.seal(&serde_json::json!({"hello":"world"})).unwrap(); + frame.pop(); + + assert!(master.open::(&frame).is_err()); +} + +#[test] +fn secure_channel_rejects_tampered_session_id() { + let (mut master, mut minion) = channels(); + let frame = minion.seal(&serde_json::json!({"hello":"world"})).unwrap(); + let mut parsed = serde_json::from_slice::(&frame).unwrap(); + match &mut parsed { + SecureFrame::Data(data) => data.session_id = "wrong-session".to_string(), + _ => panic!("expected data frame"), + } + + assert!(master.open::(&serde_json::to_vec(&parsed).unwrap()).is_err()); +} + +#[test] +fn secure_channel_rejects_tampered_key_id() { + let (mut master, mut minion) = channels(); + let frame = minion.seal(&serde_json::json!({"hello":"world"})).unwrap(); + let mut parsed = serde_json::from_slice::(&frame).unwrap(); + match &mut parsed { + SecureFrame::Data(data) => data.key_id = "wrong-key".to_string(), + _ => panic!("expected data frame"), + } + + assert!(master.open::(&serde_json::to_vec(&parsed).unwrap()).is_err()); +} + +#[test] +fn secure_channel_rejects_tampered_nonce() { + let (mut master, mut minion) = channels(); + let frame = minion.seal(&serde_json::json!({"hello":"world"})).unwrap(); + let mut parsed = serde_json::from_slice::(&frame).unwrap(); + match &mut parsed { + SecureFrame::Data(data) => data.nonce = "AA==".to_string(), + _ => panic!("expected data frame"), + } + + assert!(master.open::(&serde_json::to_vec(&parsed).unwrap()).is_err()); +} From 50d13dd220e42226be3c1fd6fe690efbb5e1db9a Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 16:03:59 +0100 Subject: [PATCH 25/43] Add secure transport contract InT --- .../tests/secure_transport_contract.rs | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 libsysinspect/tests/secure_transport_contract.rs diff --git a/libsysinspect/tests/secure_transport_contract.rs b/libsysinspect/tests/secure_transport_contract.rs new file mode 100644 index 00000000..9fc10388 --- /dev/null +++ b/libsysinspect/tests/secure_transport_contract.rs @@ -0,0 +1,162 @@ +use chrono::{Duration as ChronoDuration, Utc}; +use libsysinspect::{ + rsa::{ + keys::{get_fingerprint, keygen}, + rotation::{RotationActor, RsaTransportRotator}, + }, + transport::{ + TransportKeyExchangeModel, TransportKeyStatus, TransportPeerState, TransportProvisioningMode, TransportRotationStatus, TransportStore, + secure_bootstrap::SecureBootstrapSession, + secure_channel::{SecureChannel, SecurePeerRole}, + }, +}; +use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SecureFrame}; +use rsa::RsaPublicKey; + +fn state(master_pbk: &RsaPublicKey, minion_pbk: &RsaPublicKey) -> TransportPeerState { + TransportPeerState { + minion_id: "mid-1".to_string(), + master_rsa_fingerprint: get_fingerprint(master_pbk).unwrap(), + minion_rsa_fingerprint: get_fingerprint(minion_pbk).unwrap(), + protocol_version: SECURE_PROTOCOL_VERSION, + key_exchange: TransportKeyExchangeModel::EphemeralSessionKeys, + provisioning: TransportProvisioningMode::Automatic, + approved_at: Some(Utc::now()), + active_key_id: Some("kid-1".to_string()), + last_key_id: Some("kid-1".to_string()), + last_handshake_at: None, + rotation: TransportRotationStatus::Idle, + pending_rotation_context: None, + updated_at: Utc::now(), + keys: vec![], + } +} + +fn establish_channels(state: &TransportPeerState) -> (SecureChannel, SecureChannel) { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let rebound = TransportPeerState { master_rsa_fingerprint: get_fingerprint(&master_pbk).unwrap(), minion_rsa_fingerprint: get_fingerprint(&minion_pbk).unwrap(), ..state.clone() }; + let (opening, hello) = SecureBootstrapSession::open(&rebound, &minion_prk, &master_pbk).unwrap(); + let accepted = SecureBootstrapSession::accept( + &rebound, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + rebound.active_key_id.clone(), + None, + ) + .unwrap(); + let ack = match accepted.1 { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + let minion = opening.verify_ack(&rebound, &ack, &master_pbk).unwrap(); + let master = accepted.0; + + ( + SecureChannel::new(SecurePeerRole::Master, &master).unwrap(), + SecureChannel::new(SecurePeerRole::Minion, &minion).unwrap(), + ) +} + +#[test] +fn automatic_transport_state_establishes_secure_session_roundtrip() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (_, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (mut master, mut minion) = establish_channels(&state); + + let frame = minion.seal(&serde_json::json!({"kind":"ping","value":1})).unwrap(); + let payload: serde_json::Value = master.open(&frame).unwrap(); + + assert_eq!(payload["kind"], "ping"); + assert_eq!(payload["value"], 1); + assert!(!master.session_id().is_empty()); + assert!(!minion.session_id().is_empty()); + drop(master_prk); +} + +#[test] +fn public_transport_api_rejects_replayed_frames() { + let (_, master_pbk) = keygen(2048).unwrap(); + let (_, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (mut master, mut minion) = establish_channels(&state); + + let frame = minion.seal(&serde_json::json!({"kind":"ping"})).unwrap(); + let _: serde_json::Value = master.open(&frame).unwrap(); + + assert!(master.open::(&frame).is_err()); +} + +#[test] +fn rotated_transport_state_reconnects_with_new_key_id() { + let root = tempfile::tempdir().unwrap(); + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (_, minion_pbk) = keygen(2048).unwrap(); + let store = TransportStore::new(root.path().join("transport/master/state.json")).unwrap(); + let mut state = state(&master_pbk, &minion_pbk); + store.save(&state).unwrap(); + + let mut rotator = RsaTransportRotator::new( + RotationActor::Master, + store, + &state.minion_id, + &state.master_rsa_fingerprint, + &state.minion_rsa_fingerprint, + SECURE_PROTOCOL_VERSION, + ) + .unwrap(); + let plan = rotator.plan("manual"); + let signed = rotator.sign_plan(&plan, &master_prk).unwrap(); + let rollback = rotator + .execute_signed_intent_with_overlap(&signed, &RsaPublicKey::from(&master_prk), ChronoDuration::seconds(60)) + .unwrap(); + state = rotator.state().clone(); + + assert_eq!(state.rotation, TransportRotationStatus::Idle); + assert_eq!(state.active_key_id.as_deref(), Some(signed.intent().next_key_id())); + assert!(state.keys.iter().any(|record| record.status == TransportKeyStatus::Retiring)); + + let (mut master, mut minion) = establish_channels(&state); + let frame = minion.seal(&serde_json::json!({"kind":"after-rotation"})).unwrap(); + let payload: serde_json::Value = master.open(&frame).unwrap(); + assert_eq!(payload["kind"], "after-rotation"); + + rotator.rollback(&rollback).unwrap(); +} + +#[test] +fn bootstrap_wire_shape_roundtrips_through_json() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (_, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let wire = serde_json::to_vec(&hello).unwrap(); + let parsed = serde_json::from_slice::(&wire).unwrap(); + + let ack = match SecureBootstrapSession::accept( + &state, + match &parsed { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + None, + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + + assert_eq!(ack.key_id, "kid-1"); +} From 3a9085b4d07c2d6ee3be8ec1a4f2c5c478c76c7f Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 16:04:11 +0100 Subject: [PATCH 26/43] Add more bootstrap UT --- libsysproto/src/secure/secure_ut.rs | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/libsysproto/src/secure/secure_ut.rs b/libsysproto/src/secure/secure_ut.rs index 35d1a1bb..feb6c7e3 100644 --- a/libsysproto/src/secure/secure_ut.rs +++ b/libsysproto/src/secure/secure_ut.rs @@ -127,3 +127,48 @@ fn secure_frame_serde_uses_stable_kind_tags() { "data" ); } + +#[test] +fn secure_bootstrap_ack_roundtrips_through_json() { + let frame = SecureFrame::BootstrapAck(SecureBootstrapAck { + binding: binding(), + session_id: "sid".to_string(), + key_id: "kid".to_string(), + rotation: SecureRotationMode::Rekey, + master_ephemeral_pubkey: "pubkey".to_string(), + binding_signature: "sig".to_string(), + }); + + let parsed = serde_json::from_slice::(&serde_json::to_vec(&frame).unwrap()).unwrap(); + + assert_eq!(parsed, frame); +} + +#[test] +fn secure_diagnostic_roundtrips_through_json() { + let frame = SecureFrame::BootstrapDiagnostic(SecureBootstrapDiagnostic { + code: SecureDiagnosticCode::ReplayRejected, + message: "duplicate".to_string(), + failure: SecureFailureSemantics::diagnostic(false, true), + }); + + let parsed = serde_json::from_slice::(&serde_json::to_vec(&frame).unwrap()).unwrap(); + + assert_eq!(parsed, frame); +} + +#[test] +fn secure_data_frame_roundtrips_through_json() { + let frame = SecureFrame::Data(SecureDataFrame { + protocol_version: SECURE_PROTOCOL_VERSION, + session_id: "sid".to_string(), + key_id: "kid".to_string(), + counter: 7, + nonce: "nonce".to_string(), + payload: "payload".to_string(), + }); + + let parsed = serde_json::from_slice::(&serde_json::to_vec(&frame).unwrap()).unwrap(); + + assert_eq!(parsed, frame); +} From 5f1389a9ab81d12a5e4086afbd8d131ec59e8aa8 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 16:04:31 +0100 Subject: [PATCH 27/43] Add webapi UT --- libwebapi/src/lib_ut.rs | 95 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/libwebapi/src/lib_ut.rs b/libwebapi/src/lib_ut.rs index 59a06111..300dee14 100644 --- a/libwebapi/src/lib_ut.rs +++ b/libwebapi/src/lib_ut.rs @@ -1,4 +1,29 @@ -use super::{advertised_doc_url, tls_setup_err_message}; +use super::{advertised_doc_url, load_tls_server_config, tls_paths_summary, tls_setup_err_message}; +use libsysinspect::cfg::mmconf::MasterConfig; +use std::{fs, path::Path, path::PathBuf}; + +const CERT_PEM: &str = include_str!("../tests/data/sysmaster-dev.crt"); +const KEY_PEM: &str = include_str!("../tests/data/sysmaster-dev.key"); + +fn write_cfg(root: &Path, extra: &str) -> MasterConfig { + let cfg_path = root.join("sysinspect.conf"); + fs::write( + &cfg_path, + format!( + "config:\n master:\n fileserver.models: []\n api.bind.ip: 127.0.0.1\n api.bind.port: 4202\n{extra}" + ), + ) + .unwrap(); + MasterConfig::new(cfg_path).unwrap() +} + +fn write_tls_fixture(root: &Path) -> (PathBuf, PathBuf) { + let cert = root.join("sysmaster-dev.crt"); + let key = root.join("sysmaster-dev.key"); + fs::write(&cert, CERT_PEM).unwrap(); + fs::write(&key, KEY_PEM).unwrap(); + (cert, key) +} #[test] fn advertised_doc_url_uses_http_without_tls() { @@ -17,3 +42,71 @@ fn tls_not_setup_error_points_to_real_docs_section() { "TLS is not setup for WebAPI. For more information, see Documentation chapter \"Configuration\", section \"api.tls.enabled\"." ); } + +#[test] +fn load_tls_server_config_accepts_valid_certificate_pair() { + let root = tempfile::tempdir().unwrap(); + let (cert, key) = write_tls_fixture(root.path()); + let cfg = write_cfg( + root.path(), + &format!( + " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n", + cert.display(), + key.display() + ), + ); + + assert!(load_tls_server_config(&cfg).is_ok()); +} + +#[test] +fn load_tls_server_config_rejects_missing_private_key() { + let root = tempfile::tempdir().unwrap(); + let (cert, _) = write_tls_fixture(root.path()); + let cfg = write_cfg( + root.path(), + &format!(" api.tls.enabled: true\n api.tls.cert-file: {}\n", cert.display()), + ); + + let err = load_tls_server_config(&cfg).unwrap_err().to_string(); + assert!(err.contains("api.tls.key-file")); +} + +#[test] +fn load_tls_server_config_rejects_invalid_ca_bundle() { + let root = tempfile::tempdir().unwrap(); + let (cert, key) = write_tls_fixture(root.path()); + let ca = root.path().join("invalid-ca.pem"); + fs::write(&ca, "not a pem").unwrap(); + let cfg = write_cfg( + root.path(), + &format!( + " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n api.tls.ca-file: {}\n", + cert.display(), + key.display(), + ca.display() + ), + ); + + let err = load_tls_server_config(&cfg).unwrap_err().to_string(); + assert!(err.contains("CA file")); +} + +#[test] +fn tls_paths_summary_reports_configured_locations() { + let root = tempfile::tempdir().unwrap(); + let (cert, key) = write_tls_fixture(root.path()); + let cfg = write_cfg( + root.path(), + &format!( + " api.tls.cert-file: {}\n api.tls.key-file: {}\n api.tls.ca-file: {}\n", + cert.display(), + key.display(), + cert.display() + ), + ); + + let summary = tls_paths_summary(&cfg); + assert!(summary.contains(&cert.display().to_string())); + assert!(summary.contains(&key.display().to_string())); +} From fb13962093a20c8c5ca29b21c4c5924812c5f6f8 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 16:04:50 +0100 Subject: [PATCH 28/43] Add WebAPI contract/proto InT and data --- libwebapi/tests/api_contract.rs | 192 +++++++++++++++++++++++++ libwebapi/tests/data/sysmaster-dev.crt | 30 ++++ libwebapi/tests/data/sysmaster-dev.key | 52 +++++++ 3 files changed, 274 insertions(+) create mode 100644 libwebapi/tests/api_contract.rs create mode 100644 libwebapi/tests/data/sysmaster-dev.crt create mode 100644 libwebapi/tests/data/sysmaster-dev.key diff --git a/libwebapi/tests/api_contract.rs b/libwebapi/tests/api_contract.rs new file mode 100644 index 00000000..f51235e5 --- /dev/null +++ b/libwebapi/tests/api_contract.rs @@ -0,0 +1,192 @@ +use actix_web::{App, HttpServer, web}; +use async_trait::async_trait; +use libdatastore::{cfg::DataStorageConfig, resources::DataStorage}; +use libsysinspect::cfg::mmconf::MasterConfig; +use libwebapi::{MasterInterface, MasterInterfaceType, api::{self, ApiVersions}}; +use reqwest::Certificate; +use rustls::ServerConfig; +use std::{fs, io::BufReader, path::Path, sync::Arc}; +use tokio::{sync::Mutex, task::JoinHandle, time::{Duration, sleep}}; + +const CERT_PEM: &str = include_str!("data/sysmaster-dev.crt"); +const KEY_PEM: &str = include_str!("data/sysmaster-dev.key"); + +struct TestMaster { + cfg: MasterConfig, + queries: Arc>>, + datastore: Arc>, +} + +#[async_trait] +impl MasterInterface for TestMaster { + async fn cfg(&self) -> &MasterConfig { + &self.cfg + } + + async fn query(&mut self, query: String) -> Result<(), libcommon::SysinspectError> { + self.queries.lock().await.push(query); + Ok(()) + } + + async fn datastore(&self) -> Arc> { + Arc::clone(&self.datastore) + } +} + +fn write_cfg(root: &Path, devmode: bool) -> MasterConfig { + let cfg_path = root.join("sysinspect.conf"); + fs::write( + &cfg_path, + format!( + "config:\n master:\n fileserver.models: [cm, net]\n api.bind.ip: 127.0.0.1\n api.bind.port: 4202\n api.devmode: {}\n", + if devmode { "true" } else { "false" } + ), + ) + .unwrap(); + MasterConfig::new(cfg_path).unwrap() +} + +fn tls_config() -> ServerConfig { + let mut cert_reader = BufReader::new(CERT_PEM.as_bytes()); + let certs = rustls_pemfile::certs(&mut cert_reader).collect::, _>>().unwrap(); + let mut key_reader = BufReader::new(KEY_PEM.as_bytes()); + let key = rustls_pemfile::private_key(&mut key_reader).unwrap().unwrap(); + + ServerConfig::builder().with_no_client_auth().with_single_cert(certs, key).unwrap() +} + +async fn spawn_https_server(devmode: bool) -> (String, Arc>>, JoinHandle>) { + let root = tempfile::tempdir().unwrap(); + let cfg = write_cfg(root.path(), devmode); + let queries = Arc::new(Mutex::new(Vec::new())); + let datastore = Arc::new(Mutex::new(DataStorage::new(DataStorageConfig::new(), root.path().join("datastore")).unwrap())); + let master: MasterInterfaceType = Arc::new(Mutex::new(TestMaster { cfg, queries: Arc::clone(&queries), datastore })); + + let server = HttpServer::new(move || { + let scope = api::get(devmode, ApiVersions::V1).unwrap().load(web::scope("")); + App::new().app_data(web::Data::new(master.clone())).service(scope) + }) + .bind_rustls_0_23(("127.0.0.1", 0), tls_config()) + .unwrap(); + let addr = server.addrs()[0]; + let handle = tokio::spawn(server.run()); + sleep(Duration::from_millis(100)).await; + + (format!("https://{}", addr), queries, handle) +} + +fn trusted_client() -> reqwest::Client { + reqwest::Client::builder() + .add_root_certificate(Certificate::from_pem(CERT_PEM.as_bytes()).unwrap()) + .build() + .unwrap() +} + +#[tokio::test] +async fn https_server_rejects_default_certificate_validation_for_self_signed_cert() { + let (base, _, handle) = spawn_https_server(true).await; + + let err = reqwest::Client::new() + .post(format!("{base}/api/v1/health")) + .send() + .await + .unwrap_err() + .to_string(); + + handle.abort(); + assert!(!err.is_empty()); +} + +#[tokio::test] +async fn https_auth_and_query_use_plain_json_and_bearer_token() { + let (base, queries, handle) = spawn_https_server(true).await; + let client = trusted_client(); + + let auth = client + .post(format!("{base}/api/v1/authenticate")) + .json(&serde_json::json!({"username":"dev","password":"dev"})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap(); + let token = auth["access_token"].as_str().unwrap().to_string(); + + let query = client + .post(format!("{base}/api/v1/query")) + .bearer_auth(&token) + .json(&serde_json::json!({ + "model":"cm/file-ops", + "query":"*", + "traits":"", + "mid":"", + "context":{"reason":"test"} + })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(query["status"], "success"); + assert_eq!(queries.lock().await.as_slice(), ["cm/file-ops;*;;;reason:test"]); + handle.abort(); +} + +#[tokio::test] +async fn https_query_rejects_missing_bearer_token() { + let (base, _, handle) = spawn_https_server(true).await; + let client = trusted_client(); + + let response = client + .post(format!("{base}/api/v1/query")) + .json(&serde_json::json!({ + "model":"cm/file-ops", + "query":"*", + "traits":"", + "mid":"", + "context":{} + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + handle.abort(); +} + +#[tokio::test] +async fn https_model_names_returns_plain_json_list() { + let (base, _, handle) = spawn_https_server(true).await; + let client = trusted_client(); + let auth = client + .post(format!("{base}/api/v1/authenticate")) + .json(&serde_json::json!({"username":"dev","password":"dev"})) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + let response = client + .get(format!("{base}/api/v1/model/names")) + .bearer_auth(auth["access_token"].as_str().unwrap()) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(response["models"], serde_json::json!(["cm", "net"])); + handle.abort(); +} diff --git a/libwebapi/tests/data/sysmaster-dev.crt b/libwebapi/tests/data/sysmaster-dev.crt new file mode 100644 index 00000000..ba759ddc --- /dev/null +++ b/libwebapi/tests/data/sysmaster-dev.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFJTCCAw2gAwIBAgIUYfOEyyfhlEa11Bu4xP2E5sP6fpEwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDMyMTEzNTUxMVoXDTI3MDMy +MTEzNTUxMVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF +AAOCAg8AMIICCgKCAgEAnE+jES3AsqHBc0+/Key3s9VVQCEqp5tszaZNMJVS85WQ +YlCDlik6BDnFbLEwZ8DOKr6bEn4IHEL/rOJ3vhNp2kaVXPltsPm/scdymmOQPS7V +7RO2BkzVHc9YYRNr326HF2VbiSwinQCHbrhcCBvFJItd+jwk7+mggK8QqflsvmdB +GkqSaqUJY+mop1hVfgWYjh9iaLow3tU3G3WoK/g/Cba0kyGyaaz+h8Hf/35AGPr+ +5LLSWPxON00h1juzoM9UVbAue+iF7/IKpKaoAXSDmHzeoGnyeR09Udsn4bW9GuhJ +DFNas5QhmwjWbJrH+QUzTC+8XO030kZmARNLX/wU2bpmvHtrqZMcxNn+oCcl3dHm +BhLJcdYFCPSuYoDJIyj93K0wEnSVs2WGyBPccCAL80k2TPxJeScgFnrsQeXoTlO8 +fNoFzXPnfqZM8y0+Xx+oag3LIPldwmd+yToelni2hQ0ev4Q3d/BoYzRvvYa8N4Ik +AkErNyI16BQByJSjSfcHKuDQ0mzgyhc40OcMYajq9/3EbfmTpTSNLAiegzrCJFp9 +8XE/r51Ay5zoSMWBBpeujH2QLckxXNA14t5Va8ckpsTedyGh9N+2lEjVhEd0EaGD +/D9xqwrLhskpnKpI3fWUe+d0fwT8gLU9xHmY5VUCTvDXVMg20Djx/hb7czMP8c0C +AwEAAaNvMG0wHQYDVR0OBBYEFHA9gSE8+gkTRJq7k/ja3kzTsxkxMB8GA1UdIwQY +MBaAFHA9gSE8+gkTRJq7k/ja3kzTsxkxMA8GA1UdEwEB/wQFMAMBAf8wGgYDVR0R +BBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4ICAQCUCYhwQIlB +k/ufXMqhakEdJFS+2O6KtZb81bmx1wOsk0U5os2eFChKMlcK1RJ7cmXdbxSx7z48 +6HP7hq6s37aaVaMWwnMQ2cgzxN1xn0dT0IkVD1gllxiqJUTlYHcv6Y/mpJb81LKR +sFWriQbvCmE5Bo3M1xKbozX75oGfwrsI5jr8j14vtnH7Qv86XEyEBMecHYJ2UohZ +SXmHH4od1TYGnnRmbuEze+lQttA1PWoJjLL5+CVKVgTS6hHE/+cMBCSaoqPQHBFl +TVic+TgBIcMIk/Cs/bgk2al/lXtTk4/3FOVNYQtaBZD2bw9tGsJR3wBYLDLBD2ln +BoBvOWMtwMtZaw/3YxCQGYCLEjjivvdbX7M2hz2Zq2o72MhGLFvprOtwaYz9220+ +z9tylh1a/35DrVo/VZjjyc7YnYHkxhTU7PIUNJJcqDKS3hPgiRrOJiEmGM5mhmuJ +WhUtjqIkH6Kk67TRRmEO0/jUE5PfATdWZ/QNuGYJPTRDR8MBcAxwtfVFW8Zsd9T3 +qXHSPmAPS0RaICkoEqnyzPg0Pzaq1d2gGlosVkk7V7tie9XEAzcEBcTy6F0GOVwc +IBmp/V3evJN46oY8tn1MqY51Ta5IsQ1Kqg0ZSLxIEldkwWeApJRAXS8juPYtteIj +REkVbmcpUMl/yKca77EP1R7db+PzvXzWsA== +-----END CERTIFICATE----- diff --git a/libwebapi/tests/data/sysmaster-dev.key b/libwebapi/tests/data/sysmaster-dev.key new file mode 100644 index 00000000..25910e7a --- /dev/null +++ b/libwebapi/tests/data/sysmaster-dev.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCcT6MRLcCyocFz +T78p7Lez1VVAISqnm2zNpk0wlVLzlZBiUIOWKToEOcVssTBnwM4qvpsSfggcQv+s +4ne+E2naRpVc+W2w+b+xx3KaY5A9LtXtE7YGTNUdz1hhE2vfbocXZVuJLCKdAIdu +uFwIG8Uki136PCTv6aCArxCp+Wy+Z0EaSpJqpQlj6ainWFV+BZiOH2JoujDe1Tcb +dagr+D8JtrSTIbJprP6Hwd//fkAY+v7kstJY/E43TSHWO7Ogz1RVsC576IXv8gqk +pqgBdIOYfN6gafJ5HT1R2yfhtb0a6EkMU1qzlCGbCNZsmsf5BTNML7xc7TfSRmYB +E0tf/BTZuma8e2upkxzE2f6gJyXd0eYGEslx1gUI9K5igMkjKP3crTASdJWzZYbI +E9xwIAvzSTZM/El5JyAWeuxB5ehOU7x82gXNc+d+pkzzLT5fH6hqDcsg+V3CZ37J +Oh6WeLaFDR6/hDd38GhjNG+9hrw3giQCQSs3IjXoFAHIlKNJ9wcq4NDSbODKFzjQ +5wxhqOr3/cRt+ZOlNI0sCJ6DOsIkWn3xcT+vnUDLnOhIxYEGl66MfZAtyTFc0DXi +3lVrxySmxN53IaH037aUSNWER3QRoYP8P3GrCsuGySmcqkjd9ZR753R/BPyAtT3E +eZjlVQJO8NdUyDbQOPH+FvtzMw/xzQIDAQABAoICABZDKWBq+cT3UMwRkZJxCoDs +Y2Xs01xnwIlRpDDFM7lJlfTKrtMWMBMl/z5JxjEgvrxLxV5O4OzVhgCjiJZjwXG7 +F87UH5FTIMA7PdFLWOG95+4KHqSrELdcLqQ01epOnaLxZqYUySE/UAqu6zykZ+Ga +j9nx8vjQd3GcfW0X/yrnHdiWwl+5/apjPwgGhraaKW4kfimYSxmRmHWqvjb09lV1 +1iYWaIiwgNfo/vQukQZ9yQvdhCP0W1d4/ta6Tg0bOlGx9Azlwx23hViJ++epJozz +S+ng7Q3e4jrkUbvN3I8WgkDlJkfpUxf3nEJ/kPegi/vP2K4LgyXJrQF+NAAJsRZQ +Y8v1XfESKM028zLYryya7HxWSsg4q+tmZafL5H5323SiQi4SJ7P82+zYQ1Lie3g9 +wqCf4wkBf0iLPTeZQeYxFuCJnyhXDQQ4GjHJXHa5XUkp/1nGeZfwnN2XIZboVFiA +ckz/fvaAc4GJWrVu+/W3HL+Qy+zRRWiRlmIplKrNhGg1RLX6tqsfNDPf/z0YQAUA +eWdeoTZCw88wyxcL34PGKAHMjKtOElBarTEf8Gm5oaRPx1m9QnKH6wQWOcxy779s +1lAyfFtJiAWeC7QF7zCp652Fihu/tWMAWCH5ek4fZwiRybk4gLfvyYsiqt9OKCyj +Qkg1Ou9oiPWQtcts6QSpAoIBAQDVEFUuoRhYS+ZvAXUzpDMn2afYlvJ94U8hikyx +zN6TX/xRZoa2mkkP80qi3VncmnN/KGzerEe5vp9lOFP1ltlXzfZ0ZNLKIYRdn3LH +Pjz7N1Hg5gCOrFdxwG0PmViXMskDOfzuTdb1n3vq7gmY7CaOvR3belsNN88ZtMsF +M9Eh8Aikzb4RvpTxtdlVagNXH/2Xfq8Auf2c6QTO+dqcwdfGgiRNTz/Cea/BKz25 +G6+mvjt2MXvUui5AcK3OAqe0NaaP+pN8vKKAx07D8FY7nDnY9UBH2Wt278Cqj6RW +9mgN6/tR0fhN9otDiU06pni0eOmvjXJpTE29ZEcetlhStEWrAoIBAQC7z4KHtNVa +zH9rQHpvzahpnua8VVCt3ww0pBt4h12kifOCmd6PsTHuBACJEodEM5raYGtDovuo +ENRuPhTtJoWh42ZnZHoi6CvXLNGl89gKVNSdqL1NQREo2D7DzYmGlqLW26txb7Cv +CEJf71U0syOuYkQS4F+ISbCQko0ctu9rhQMkPEHYWIRNoLkH3gTQJOcv9OWIcKQj +IYyFxZMysWABT+R+GuW2v+lPtertosjQs9CiHr/e9T2GyW5L5aTzt3q7j+Ftlqsg +XbbmHSDWKdUCo7QXa3Ppnid9y6iGU/PbHrY1XAMdp8ODi+dqOdgObAb6VpHqGijd +8z7LoxCZeb5nAoIBAF+YAF/3b1DOXQkZAli1Jy6N/Ty0HQBVgodt4ZM0c/hzbGWp +Nm/fMUCyy53e6l6L/Z3jqVUOvu+bkzB64VCi6cj3Y8g9JEYEW7sVuw2h4wJjg50A +FOfucx1aVJRXHORZqM6FyfGxguyZLaPuQOgXrAUG3MqITynTDFxgPWaMJRyw8W/f +z5Nuiq0YBfbIpc8FT2YVNLeCu0MXWUzz1R3X6tPpuBfnopfCuRRWLk9LGLgbSdpx +wTlkfzPyWki/8DZui9i1eE7S46Ybxj8rKcV9BodNIhYaepjWYP8li3po+66jXhML +vfhc0Ybvp3LVFfsC9PYK5HZSAd8jirVA4sfYkhkCggEBAJAVJIjD/KKKHH7FmqjH +WBqfo1h9A0ZAxfZkqAaRow+mHcDmFs6aHDoDq/18z3VNOdGrAt+C3BoVv0NMMXW2 +hfKqqFdNyD2bbHbJlZUBO47BgdPqLkBkWKvDKnPA7W7phcfcAu0lyKCfb3x1+iJS +BF+2V487v06paeGf7M5IsekExGI6MDGvxuBfG1SjyYF9rjcmZCmGcQXaqRm/d6v+ +VC7tgdgU/oJzPKTAZZklt3YVXUvi10RPVIJhalKjvSaUbn4SZdlTK7nK65QiaJyk +vxwlRvZooyZpBNcHNSTIp15Fc3gAPQu1NtNms4TVF6II0lmfrJWyuAN+p4BGe2ei +9KUCggEAV1FYRd7t/fBVzq0qp47LeA0yYeZX139VrM8iSAphqpqGVCbc54h+3GJA +7F7maf2vtBI9+iq8VyWVcMTHtFuwL1G9NXmfYU+RS3vF5SmG5WKD2GYr6fAk8axY +96diomP0suzW/N4gmsInEYFVtOLbeZF2w/9wYtka2JHCGivU5eUC+HVpkxEMpt96 +FNPAcJ9/PPdU/bGBy26fPOI8+cybHqFxbmKfZtz81wKY1neM89MA+bKhEgO2g3na +cr+hZIKWz7gondC4meA0DeGX70VkkIndkPKjIsANM2hKAF+lMc+4xtTVBdC1E+DG +TCBm7jJaYdu2PBza/IFRwL2pASrr/g== +-----END PRIVATE KEY----- From c08c6e3ebc1b693640b7f096097a16a8af9a46a8 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 16:05:05 +0100 Subject: [PATCH 29/43] Add sysclient UT --- sysclient/src/lib.rs | 3 +++ sysclient/src/lib_ut.rs | 57 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 sysclient/src/lib_ut.rs diff --git a/sysclient/src/lib.rs b/sysclient/src/lib.rs index 64056fb9..e3a358b4 100644 --- a/sysclient/src/lib.rs +++ b/sysclient/src/lib.rs @@ -4,6 +4,9 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{collections::BTreeMap, collections::HashMap}; +#[cfg(test)] +mod lib_ut; + /// SysClient Configuration /// This struct holds the configuration for the SysClient, including the root directory. /// It can be extended in the future to include more configuration options. diff --git a/sysclient/src/lib_ut.rs b/sysclient/src/lib_ut.rs new file mode 100644 index 00000000..f5f23dd2 --- /dev/null +++ b/sysclient/src/lib_ut.rs @@ -0,0 +1,57 @@ +use super::{SysClient, SysClientConfiguration}; +use libcommon::SysinspectError; +use serde_json::json; + +#[test] +fn default_configuration_uses_https_localhost() { + assert_eq!(SysClientConfiguration::default().master_url, "https://localhost:4202"); +} + +#[tokio::test] +async fn query_requires_authentication_first() { + let client = SysClient::new(SysClientConfiguration { master_url: "https://localhost:4202".to_string() }); + let err = client.query("cm/file-ops", "*", "", "", json!({})).await.unwrap_err().to_string(); + + assert!(err.contains("not authenticated")); +} + +#[tokio::test] +async fn models_require_authentication_first() { + let client = SysClient::new(SysClientConfiguration { master_url: "https://localhost:4202".to_string() }); + let err = client.models().await.unwrap_err().to_string(); + + assert!(err.contains("not authenticated")); +} + +#[tokio::test] +async fn model_descr_requires_authentication_first() { + let client = SysClient::new(SysClientConfiguration { master_url: "https://localhost:4202".to_string() }); + let err = client.model_descr("cm").await.unwrap_err().to_string(); + + assert!(err.contains("not authenticated")); +} + +#[test] +fn query_context_must_be_json_object() { + let err = match SysClient::context_map(json!(["not", "an", "object"])) { + Ok(_) => panic!("expected serialization error"), + Err(SysinspectError::SerializationError(err)) => err, + Err(other) => panic!("unexpected error: {other}"), + }; + + assert!(err.contains("JSON object")); +} + +#[test] +fn query_context_stringifies_non_string_values() { + let context = SysClient::context_map(json!({ + "n": 42, + "b": true, + "s": "text" + })) + .unwrap(); + + assert_eq!(context.get("n"), Some(&"42".to_string())); + assert_eq!(context.get("b"), Some(&"true".to_string())); + assert_eq!(context.get("s"), Some(&"text".to_string())); +} From 826adbb900aec81d1aee09d7dd7b2cdc9460be24 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 16:05:13 +0100 Subject: [PATCH 30/43] Add sysclient InT --- sysclient/tests/client_contract.rs | 85 ++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 sysclient/tests/client_contract.rs diff --git a/sysclient/tests/client_contract.rs b/sysclient/tests/client_contract.rs new file mode 100644 index 00000000..74392a01 --- /dev/null +++ b/sysclient/tests/client_contract.rs @@ -0,0 +1,85 @@ +use actix_web::{App, HttpServer, web}; +use async_trait::async_trait; +use libdatastore::{cfg::DataStorageConfig, resources::DataStorage}; +use libsysinspect::cfg::mmconf::MasterConfig; +use libwebapi::{MasterInterface, MasterInterfaceType, api::{self, ApiVersions}}; +use std::{fs, path::Path, sync::Arc}; +use sysinspect_client::{ModelNameResponse, QueryResponse, SysClient, SysClientConfiguration}; +use tokio::{sync::Mutex, task::JoinHandle, time::{Duration, sleep}}; + +struct TestMaster { + cfg: MasterConfig, + queries: Arc>>, + datastore: Arc>, +} + +#[async_trait] +impl MasterInterface for TestMaster { + async fn cfg(&self) -> &MasterConfig { + &self.cfg + } + + async fn query(&mut self, query: String) -> Result<(), libcommon::SysinspectError> { + self.queries.lock().await.push(query); + Ok(()) + } + + async fn datastore(&self) -> Arc> { + Arc::clone(&self.datastore) + } +} + +fn write_cfg(root: &Path) -> MasterConfig { + let cfg_path = root.join("sysinspect.conf"); + fs::write( + &cfg_path, + "config:\n master:\n fileserver.models: [cm, net]\n api.bind.ip: 127.0.0.1\n api.bind.port: 4202\n api.devmode: true\n", + ) + .unwrap(); + MasterConfig::new(cfg_path).unwrap() +} + +async fn spawn_http_server() -> (String, Arc>>, JoinHandle>) { + let root = tempfile::tempdir().unwrap(); + let cfg = write_cfg(root.path()); + let queries = Arc::new(Mutex::new(Vec::new())); + let datastore = Arc::new(Mutex::new(DataStorage::new(DataStorageConfig::new(), root.path().join("datastore")).unwrap())); + let master: MasterInterfaceType = Arc::new(Mutex::new(TestMaster { cfg, queries: Arc::clone(&queries), datastore })); + let server = HttpServer::new(move || { + let scope = api::get(true, ApiVersions::V1).unwrap().load(web::scope("")); + App::new().app_data(web::Data::new(master.clone())).service(scope) + }) + .bind(("127.0.0.1", 0)) + .unwrap(); + let addr = server.addrs()[0]; + let handle = tokio::spawn(server.run()); + sleep(Duration::from_millis(100)).await; + + (format!("http://{}", addr), queries, handle) +} + +#[tokio::test] +async fn client_authenticates_and_executes_plain_json_query() { + let (base, queries, handle) = spawn_http_server().await; + let mut client = SysClient::new(SysClientConfiguration { master_url: base, }); + + let token = client.authenticate("dev", "dev").await.unwrap(); + let response: QueryResponse = client.query("cm/file-ops", "*", "", "", serde_json::json!({"reason":"test"})).await.unwrap(); + + assert_eq!(token, "dev-token"); + assert_eq!(response.status, "success"); + assert_eq!(queries.lock().await.as_slice(), ["cm/file-ops;*;;;reason:test"]); + handle.abort(); +} + +#[tokio::test] +async fn client_lists_models_using_bearer_auth() { + let (base, _, handle) = spawn_http_server().await; + let mut client = SysClient::new(SysClientConfiguration { master_url: base }); + client.authenticate("dev", "dev").await.unwrap(); + + let models: ModelNameResponse = client.models().await.unwrap(); + + assert_eq!(models.models, vec!["cm".to_string(), "net".to_string()]); + handle.abort(); +} From 1ab9374dc7cf2cdb87930fe725b332311bdb6af2 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 16:05:28 +0100 Subject: [PATCH 31/43] Add minion transport UT --- sysmaster/src/transport.rs | 4 + sysmaster/src/transport_ut.rs | 151 ++++++++++++++++++++++++++++++++++ sysminion/src/minion_ut.rs | 61 ++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 sysmaster/src/transport_ut.rs diff --git a/sysmaster/src/transport.rs b/sysmaster/src/transport.rs index 16be5858..b248f72a 100644 --- a/sysmaster/src/transport.rs +++ b/sysmaster/src/transport.rs @@ -21,6 +21,10 @@ use std::{ time::{Duration as StdDuration, Instant}, }; +#[cfg(test)] +#[path = "transport_ut.rs"] +mod transport_ut; + const BOOTSTRAP_MALFORMED_WINDOW: StdDuration = StdDuration::from_secs(30); const BOOTSTRAP_REPLAY_WINDOW: StdDuration = StdDuration::from_secs(300); diff --git a/sysmaster/src/transport_ut.rs b/sysmaster/src/transport_ut.rs new file mode 100644 index 00000000..6d16b4a5 --- /dev/null +++ b/sysmaster/src/transport_ut.rs @@ -0,0 +1,151 @@ +use super::{PeerConnection, PeerTransport}; +use chrono::Utc; +use libsysinspect::{ + rsa::keys::{get_fingerprint, keygen}, + transport::{ + TransportKeyExchangeModel, TransportPeerState, TransportProvisioningMode, TransportRotationStatus, + secure_bootstrap::SecureBootstrapSession, + secure_channel::{SecureChannel, SecurePeerRole}, + }, +}; +use libsysproto::{ + MasterMessage, ProtoConversion, + rqtypes::RequestType, + secure::{SECURE_PROTOCOL_VERSION, SecureFrame}, +}; +use rsa::RsaPublicKey; + +fn state(master_pbk: &RsaPublicKey, minion_pbk: &RsaPublicKey) -> TransportPeerState { + TransportPeerState { + minion_id: "mid-1".to_string(), + master_rsa_fingerprint: get_fingerprint(master_pbk).unwrap(), + minion_rsa_fingerprint: get_fingerprint(minion_pbk).unwrap(), + protocol_version: SECURE_PROTOCOL_VERSION, + key_exchange: TransportKeyExchangeModel::EphemeralSessionKeys, + provisioning: TransportProvisioningMode::Automatic, + approved_at: Some(Utc::now()), + active_key_id: Some("kid-1".to_string()), + last_key_id: Some("kid-1".to_string()), + last_handshake_at: None, + rotation: TransportRotationStatus::Idle, + pending_rotation_context: None, + updated_at: Utc::now(), + keys: vec![], + } +} + +fn channels() -> (SecureChannel, SecureChannel) { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (opening, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let accepted = SecureBootstrapSession::accept( + &state, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + None, + ) + .unwrap(); + let ack = match accepted.1 { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + let minion = opening.verify_ack(&state, &ack, &master_pbk).unwrap(); + let master = accepted.0; + + ( + SecureChannel::new(SecurePeerRole::Master, &master).unwrap(), + SecureChannel::new(SecurePeerRole::Minion, &minion).unwrap(), + ) +} + +#[test] +fn encode_message_stays_plaintext_without_session() { + let mut transport = PeerTransport::new(); + let msg = MasterMessage::new(RequestType::Ping, serde_json::json!("general")); + + let encoded = transport.encode_message("127.0.0.1:4200", &msg).unwrap(); + + assert_eq!(encoded, msg.sendable().unwrap()); +} + +#[test] +fn encode_message_seals_payload_with_active_session() { + let mut transport = PeerTransport::new(); + let (master_channel, mut minion_channel) = channels(); + transport.peers.insert( + "127.0.0.1:4200".to_string(), + PeerConnection { minion_id: "mid-1".to_string(), channel: master_channel }, + ); + let msg = MasterMessage::new(RequestType::Ping, serde_json::json!("general")); + + let encoded = transport.encode_message("127.0.0.1:4200", &msg).unwrap(); + let opened = minion_channel.open_bytes(&encoded).unwrap(); + + assert_eq!(opened, msg.sendable().unwrap()); +} + +#[test] +fn decode_frame_drops_invalid_secure_session_state() { + let mut transport = PeerTransport::new(); + let (master_channel, mut minion_channel) = channels(); + transport.peers.insert( + "127.0.0.1:4200".to_string(), + PeerConnection { minion_id: "mid-1".to_string(), channel: master_channel }, + ); + let mut frame = minion_channel.seal(&serde_json::json!({"hello":"world"})).unwrap(); + frame.pop(); + + let root = tempfile::tempdir().unwrap(); + let err = transport + .decode_frame( + "127.0.0.1:4200", + &frame, + &libsysinspect::cfg::mmconf::MasterConfig::default(), + &mut crate::registry::mkb::MinionsKeyRegistry::new(root.path().to_path_buf()).unwrap(), + ) + .unwrap_err() + .to_string(); + + assert!(err.contains("decode secure frame") || err.contains("decode secure payload") || err.contains("Expected encrypted secure data frame")); + assert!(!transport.peers.contains_key("127.0.0.1:4200")); +} + +#[test] +fn remove_peer_clears_secure_and_plaintext_tracking() { + let mut transport = PeerTransport::new(); + let (master_channel, _) = channels(); + transport.allow_plaintext("127.0.0.1:4200"); + transport.peers.insert( + "127.0.0.1:4200".to_string(), + PeerConnection { minion_id: "mid-1".to_string(), channel: master_channel }, + ); + + transport.remove_peer("127.0.0.1:4200"); + + assert!(!transport.peers.contains_key("127.0.0.1:4200")); + assert!(!transport.plaintext_peers.contains("127.0.0.1:4200")); +} + +#[test] +fn plaintext_diag_rejects_non_registration_traffic() { + let diag = PeerTransport::plaintext_diag(br#"{"id":"mid-1","r":"ehlo","d":{},"c":0,"sid":"sid-1"}"#).unwrap(); + + assert!(matches!( + serde_json::from_slice::(&diag).unwrap(), + SecureFrame::BootstrapDiagnostic(frame) if frame.message.contains("secure bootstrap is required") + )); +} + +#[test] +fn bootstrap_diag_ignores_non_secure_plaintext() { + let mut failures = std::collections::HashMap::new(); + + assert!(PeerTransport::bootstrap_diag_with_state(&mut failures, "127.0.0.1:4200", br#"{"hello":"world"}"#).is_none()); +} diff --git a/sysminion/src/minion_ut.rs b/sysminion/src/minion_ut.rs index 83c861d5..006dbf1c 100644 --- a/sysminion/src/minion_ut.rs +++ b/sysminion/src/minion_ut.rs @@ -634,4 +634,65 @@ mod tests { // Should not panic minion.request(b"abc".to_vec()).await; } + + #[tokio::test] + async fn bootstrap_secure_fails_on_truncated_reply_frame() { + let _guard = TEST_LOCK.lock().await; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut lenb = [0u8; 4]; + sock.read_exact(&mut lenb).await.unwrap(); + let mut hello = vec![0u8; u32::from_be_bytes(lenb) as usize]; + sock.read_exact(&mut hello).await.unwrap(); + assert!(!hello.is_empty()); + + sock.write_all(&32u32.to_be_bytes()).await.unwrap(); + sock.write_all(br#"{"kind":"bootstrap_ack""#).await.unwrap(); + sock.flush().await.unwrap(); + }); + + let tmp = tempfile::tempdir().unwrap(); + let (_, master_pbk) = keygen(2048).unwrap(); + key_to_file(&Public(master_pbk), tmp.path().to_str().unwrap(), CFG_MASTER_KEY_PUB).unwrap(); + + let mut cfg = MinionConfig::default(); + cfg.set_master_ip(&addr.ip().to_string()); + cfg.set_master_port(addr.port().into()); + cfg.set_root_dir(tmp.path().to_str().unwrap()); + + let dpq = Arc::new(DiskPersistentQueue::open(tmp.path().join("pending-tasks")).unwrap()); + let minion = SysMinion::new(cfg, None, dpq).await.unwrap(); + let err = minion.bootstrap_secure().await.unwrap_err().to_string(); + + assert!(err.contains("decode secure bootstrap reply") || err.contains("early eof")); + } + + #[tokio::test] + async fn repeated_reconnect_signals_do_not_hang_instance_shutdown() { + let _guard = TEST_LOCK.lock().await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let (_sock, _) = listener.accept().await.unwrap(); + tokio::time::sleep(Duration::from_secs(60)).await; + }); + + let tmp = tempfile::tempdir().unwrap(); + let cfg = mk_cfg(format!("{addr}"), "127.0.0.1:1".to_string(), tmp.path()); + let dpq = Arc::new(DiskPersistentQueue::open(tmp.path().join("pending-tasks")).unwrap()); + let handle = tokio::spawn(async move { _minion_instance(cfg, None, dpq).await }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let _ = CONNECTION_TX.send(()); + let _ = CONNECTION_TX.send(()); + let _ = CONNECTION_TX.send(()); + + assert!(timeout(Duration::from_secs(2), handle).await.is_ok(), "instance did not exit under reconnect storm"); + } } From d93cccce34436e730b17f46a05f125e3d4ca5743 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 16:12:22 +0100 Subject: [PATCH 32/43] Update documentation --- docs/apidoc/overview.rst | 65 ++++++- docs/genusage/operator_security.rst | 189 +++++++++++++++++++ docs/genusage/overview.rst | 3 + docs/genusage/secure_transport.rst | 4 + docs/genusage/security_model.rst | 104 +++++++++++ docs/genusage/transport_protocol.rst | 220 +++++++++++++++++++++++ docs/index.rst | 1 + docs/tutorial/secure_transport_tutor.rst | 161 +++++++++++++++++ 8 files changed, 738 insertions(+), 9 deletions(-) create mode 100644 docs/genusage/operator_security.rst create mode 100644 docs/genusage/security_model.rst create mode 100644 docs/genusage/transport_protocol.rst create mode 100644 docs/tutorial/secure_transport_tutor.rst diff --git a/docs/apidoc/overview.rst b/docs/apidoc/overview.rst index fb6f63a3..17107582 100644 --- a/docs/apidoc/overview.rst +++ b/docs/apidoc/overview.rst @@ -1,18 +1,65 @@ Web API ======= -This section provides an overview of the Web API for SysInspect, including its structure, endpoints, and usage. +This section provides an overview of the Web API for SysInspect, including its +HTTPS/TLS access pattern, endpoints, and request flow. Accessing the Documentation --------------------------- -The Web API is automatically documented using Swagger, which provides a user-friendly interface to explore the available endpoints -and their parameters. You can access the Swagger UI at the following URL: +The Web API is automatically documented using Swagger, which provides a +user-friendly interface to explore the available endpoints and their +parameters. You can access the Swagger UI at: -``` -http://:4202/doc/ -``` +``https://:4202/doc/`` -This interface, running on default port **4202**, allows you to interact with the API, view the available endpoints, -and test them directly from your browser. You can change the port and API version in the general ``sysinspect.conf`` -configuration file. \ No newline at end of file +This interface runs on default port **4202**. + +The embedded Web API only starts when TLS is configured correctly under +``api.tls.*`` in ``sysinspect.conf``. + +Authentication And Requests +--------------------------- + +The Web API uses: + +- HTTPS/TLS for transport protection +- bearer tokens for authentication +- plain JSON request and response bodies + +Typical flow: + +1. ``POST /api/v1/authenticate`` with JSON credentials +2. receive ``access_token`` +3. call later endpoints with ``Authorization: Bearer `` + +Example authentication request body: + +.. code-block:: json + + { + "username": "operator", + "password": "secret" + } + +Example query request body: + +.. code-block:: json + + { + "model": "cm/file-ops", + "query": "*", + "traits": "", + "mid": "", + "context": { + "reason": "manual-run" + } + } + +Related Material +---------------- + +- :doc:`../global_config` +- :doc:`../genusage/operator_security` +- ``examples/transport-fixtures/webapi_auth_request.json`` +- ``examples/transport-fixtures/webapi_query_request.json`` diff --git a/docs/genusage/operator_security.rst b/docs/genusage/operator_security.rst new file mode 100644 index 00000000..db0a7fc9 --- /dev/null +++ b/docs/genusage/operator_security.rst @@ -0,0 +1,189 @@ +Operator Security Guide +======================= + +This page collects the operator-facing secure transport and Web API procedures +in one place. + +Registration +------------ + +When a minion starts without an existing trust relationship, it reports the +master fingerprint and waits for registration. + +Typical operator flow: + +1. Start ``sysmaster``. +2. Start the minion once and note the reported master fingerprint. +3. Verify that fingerprint out-of-band. +4. Register the minion with ``sysminion --register ``. +5. Start the minion normally with ``sysminion --start``. + +After registration: + +- the master stores the minion RSA public key +- the minion stores the master RSA public key +- both sides create managed transport state on disk +- later reconnects bootstrap secure sessions automatically + +Key And State Locations +----------------------- + +Under the Sysinspect root, the important files are: + +Master: + +- ``master.rsa``: master RSA private key +- ``master.rsa.pub``: master RSA public key +- ``console.rsa``: local console private key +- ``console.rsa.pub``: local console public key +- ``console-keys/``: authorized console client public keys +- ``transport/minions//state.json``: managed transport state for + one minion + +Minion: + +- ``minion.rsa``: minion RSA private key +- ``minion.rsa.pub``: minion RSA public key +- ``master.rsa.pub``: trusted master RSA public key +- ``transport/master/state.json``: managed transport state for the current + master + +These files are managed by Sysinspect. Do not edit the transport state files by +hand during normal operation. + +Secure Transport Verification +----------------------------- + +The quickest operator checks are: + +- ``sysinspect network --status`` +- master and minion logs + +The transport status view shows: + +- active key id +- last successful handshake time +- last rotation time +- current rotation state + +Healthy behavior looks like: + +- the minion reconnects cleanly +- the master logs secure session establishment +- ``network --status`` shows a current handshake timestamp + +Transport Key Rotation +---------------------- + +Rotate one minion: + +.. code-block:: bash + + sysinspect network --rotate --id + +Rotate a group: + +.. code-block:: bash + + sysinspect network --rotate 'edge-*' + +Inspect state: + +.. code-block:: bash + + sysinspect network --status '*' + +Important behavior: + +- online minions receive the signed rotation intent immediately +- offline minions keep a pending rotation state until reconnect +- the reconnect after rotation establishes a fresh secure session +- old key material is kept briefly as retiring overlap and then removed + +Troubleshooting +--------------- + +Check the logs first. + +Common master-side messages: + +- secure bootstrap authentication failure +- replay rejection +- duplicate session rejection +- version mismatch or malformed bootstrap rejection +- staged rotation key mismatch +- Web API TLS startup failure + +Common minion-side messages: + +- missing trusted master key +- missing managed transport state +- bootstrap diagnostic returned by the master +- bootstrap ack verification failure +- reconnect triggered after transport failure + +Typical recovery paths: + +- stale or broken minion trust: re-register the minion +- changed master identity: re-register affected minions +- pending rotation on an offline minion: let it reconnect, then check + ``network --status`` +- bad Web API TLS file paths: fix ``api.tls.*`` and restart ``sysmaster`` + +Web API TLS Setup +----------------- + +The embedded Web API is separate from the Master/Minion secure transport. +It uses normal HTTPS/TLS. + +Required configuration: + +.. code-block:: yaml + + config: + master: + api.enabled: true + api.tls.enabled: true + api.tls.cert-file: etc/web/api.crt + api.tls.key-file: etc/web/api.key + +Optional configuration: + +.. code-block:: yaml + + config: + master: + api.tls.ca-file: trust/ca.pem + api.tls.allow-insecure: false + +Behavior: + +- if ``api.enabled`` is ``true`` but TLS is not configured correctly, the Web + API stays disabled +- ``sysmaster`` itself keeps running +- Swagger UI is served over ``https://:4202/doc/`` by default + +Re-Registration And Replacement +------------------------------- + +Use re-registration when: + +- the minion was rebuilt or replaced +- the master identity changed +- trust files were lost or corrupted + +A clean replacement flow is: + +1. unregister the old minion identity from the master if needed +2. start the replacement minion once and verify the current master fingerprint +3. register the replacement minion +4. start it normally +5. confirm secure handshake and transport status + +Related Material +---------------- + +- :doc:`secure_transport` +- :doc:`transport_protocol` +- :doc:`security_model` +- :doc:`../apidoc/overview` diff --git a/docs/genusage/overview.rst b/docs/genusage/overview.rst index bcbef776..b984e7e0 100644 --- a/docs/genusage/overview.rst +++ b/docs/genusage/overview.rst @@ -48,6 +48,9 @@ sections: cli distributed_model secure_transport + transport_protocol + operator_security + security_model systraits targeting virtual_minions diff --git a/docs/genusage/secure_transport.rst b/docs/genusage/secure_transport.rst index 9d20e48e..caafea57 100644 --- a/docs/genusage/secure_transport.rst +++ b/docs/genusage/secure_transport.rst @@ -11,6 +11,10 @@ hand or understand the protocol internals. This page is only about the Master/Minion link. It does not describe the Web API. +For the exact wire format, see :doc:`transport_protocol`. +For operator procedures, see :doc:`operator_security`. +For threat coverage and limits, see :doc:`security_model`. + What You Need To Know --------------------- diff --git a/docs/genusage/security_model.rst b/docs/genusage/security_model.rst new file mode 100644 index 00000000..4278a689 --- /dev/null +++ b/docs/genusage/security_model.rst @@ -0,0 +1,104 @@ +Security Model +============== + +This page states what each security layer in Sysinspect is responsible for. + +Master/Minion Transport +----------------------- + +For the Master/Minion link: + +- RSA identifies the master and the minion +- RSA signatures authenticate the bootstrap exchange +- ephemeral Curve25519 key exchange provides a fresh short-lived shared secret +- libsodium protects steady-state traffic after bootstrap + +What this gives you: + +- authenticated peers +- fresh per-connection session protection +- replay rejection through counters and nonce derivation +- fail-closed rejection of unsupported or malformed peers + +What it is not: + +- browser-style TLS +- a public PKI system +- a substitute for operator verification of the initial trust relationship + +Web API +------- + +For the embedded Web API: + +- TLS protects the HTTPS connection +- bearer tokens authenticate API requests +- request and response bodies are plain JSON over HTTPS + +What this gives you: + +- standard HTTPS protection for remote API calls +- standard certificate validation behavior unless explicitly relaxed by the + client side +- no custom application-layer crypto inside the JSON payloads + +What it is not: + +- part of the Master/Minion secure transport +- protected by the Master/Minion libsodium channel + +Console +------- + +The local ``sysinspect`` to ``sysmaster`` console path is a separate transport. + +It remains: + +- local to the master host +- based on its own console RSA/bootstrap mechanism +- independent from both the Web API TLS layer and the Master/Minion transport + +Threats Covered +--------------- + +The current design is meant to cover: + +- passive eavesdropping on Master/Minion traffic after registration +- tampering with secure Master/Minion frames +- replay of old secure frames +- unsupported peers attempting insecure fallback +- duplicate active sessions for the same minion +- normal remote API exposure through plaintext HTTP + +Out Of Scope +------------ + +The current design does not try to solve everything. + +Out of scope: + +- compromise of the master or minion host itself +- theft of private keys from a compromised host +- manual trust mistakes during initial fingerprint verification +- operator misuse of ``allow-insecure`` or other weak client-side trust + settings +- generic browser, PAM, LDAP, or operating-system hardening outside + Sysinspect itself + +Operational Assumptions +----------------------- + +Sysinspect assumes: + +- the initial master fingerprint is verified by the operator +- private key files are protected by host filesystem permissions +- managed transport state is not edited manually during normal operation +- Web API certificates and keys are provided and rotated through normal + operator procedures + +In short: + +- RSA authenticates identities +- Curve25519 + libsodium protect Master/Minion traffic +- TLS protects the Web API +- each layer has a separate role and should be operated that way diff --git a/docs/genusage/transport_protocol.rst b/docs/genusage/transport_protocol.rst new file mode 100644 index 00000000..a2ce8212 --- /dev/null +++ b/docs/genusage/transport_protocol.rst @@ -0,0 +1,220 @@ +Master/Minion Protocol +====================== + +This page describes the exact secure transport used between ``sysmaster`` and +``sysminion``. + +It is intentionally lower-level than :doc:`secure_transport`. Use this page +when you need the precise wire shapes, handshake order, or rejection rules. + +Frame Envelope +-------------- + +The outer wire format is length-prefixed: + +1. write a big-endian ``u32`` frame length +2. write one JSON-encoded secure frame + +Secure frame kinds are: + +- ``bootstrap_hello`` +- ``bootstrap_ack`` +- ``bootstrap_diagnostic`` +- ``data`` + +Handshake Binding +----------------- + +Every secure session is bound to: + +- minion id +- minion RSA fingerprint +- master RSA fingerprint +- secure protocol version +- connection id +- client nonce +- master nonce + +That binding is carried in ``SecureSessionBinding`` and authenticated during +bootstrap. + +Bootstrap Sequence +------------------ + +Normal secure session establishment is: + +1. The minion opens a TCP connection to the master. +2. The minion sends ``bootstrap_hello``. +3. The master validates: + - the minion is registered + - the stored RSA fingerprints match + - at least one common secure transport version exists + - the opening is not stale or replayed + - there is no conflicting active session for that minion +4. The master replies with: + - ``bootstrap_ack`` on success, or + - ``bootstrap_diagnostic`` on failure +5. Both sides derive the same short-lived session key from: + - the authenticated Curve25519 shared secret + - the completed binding + - both ephemeral public keys +6. After that, every frame on that connection must be ``data``. + +``bootstrap_hello`` +------------------- + +Fields: + +- ``binding``: initial session binding +- ``supported_versions``: secure transport versions supported by the minion +- ``client_ephemeral_pubkey``: minion ephemeral Curve25519 public key +- ``binding_signature``: minion RSA signature over the authenticated opening +- ``key_id``: optional managed transport key id for reconnect/rotation continuity + +Example: + +.. code-block:: json + + { + "kind": "bootstrap_hello", + "binding": { + "minion_id": "minion-a", + "minion_rsa_fingerprint": "minion-fp", + "master_rsa_fingerprint": "master-fp", + "protocol_version": 1, + "connection_id": "conn-1", + "client_nonce": "client-nonce", + "master_nonce": "", + "timestamp": 1734739200 + }, + "supported_versions": [1], + "client_ephemeral_pubkey": "", + "binding_signature": "", + "key_id": "trk-current" + } + +``bootstrap_ack`` +----------------- + +Fields: + +- ``binding``: completed binding with the master nonce filled in +- ``session_id``: master-assigned secure session id +- ``key_id``: accepted transport key id +- ``rotation``: ``none``, ``rekey``, or ``reregister`` +- ``master_ephemeral_pubkey``: master ephemeral Curve25519 public key +- ``binding_signature``: master RSA signature over the authenticated ack + +Example: + +.. code-block:: json + + { + "kind": "bootstrap_ack", + "binding": { + "minion_id": "minion-a", + "minion_rsa_fingerprint": "minion-fp", + "master_rsa_fingerprint": "master-fp", + "protocol_version": 1, + "connection_id": "conn-1", + "client_nonce": "client-nonce", + "master_nonce": "master-nonce", + "timestamp": 1734739200 + }, + "session_id": "sid-1", + "key_id": "trk-current", + "rotation": "none", + "master_ephemeral_pubkey": "", + "binding_signature": "" + } + +``bootstrap_diagnostic`` +------------------------ + +This is the only failure frame allowed before a secure session exists. + +Fields: + +- ``code``: ``unsupported_version``, ``bootstrap_rejected``, + ``replay_rejected``, ``rate_limited``, ``malformed_frame``, or + ``duplicate_session`` +- ``message``: human-readable rejection reason +- ``failure``: retry/disconnect semantics + +Example: + +.. code-block:: json + + { + "kind": "bootstrap_diagnostic", + "code": "replay_rejected", + "message": "Secure bootstrap replay rejected for minion-a", + "failure": { + "retryable": false, + "disconnect": true, + "rate_limit": true + } + } + +``data`` +-------- + +After bootstrap succeeds, all traffic uses ``data``. + +Fields: + +- ``protocol_version``: negotiated secure transport version +- ``session_id``: active secure session id +- ``key_id``: active managed transport key id +- ``counter``: per-direction monotonic counter +- ``nonce``: counter-derived libsodium nonce +- ``payload``: authenticated encrypted payload + +Example: + +.. code-block:: json + + { + "kind": "data", + "protocol_version": 1, + "session_id": "sid-1", + "key_id": "trk-current", + "counter": 1, + "nonce": "", + "payload": "" + } + +Enforcement Rules +----------------- + +The transport fails closed. + +Important rules: + +- unsupported peers do not fall back silently +- plaintext registration remains the only allowed non-secure setup path +- plaintext ``ehlo`` and other normal minion traffic are rejected +- duplicate secure sessions for the same minion are rejected +- replayed bootstrap openings are rejected +- replayed, duplicated, stale, or tampered ``data`` frames are rejected +- reconnects create a new connection id, new nonces, and a fresh short-lived + session key + +Rotation Interaction +-------------------- + +Managed transport rotation does not change the wire shape. + +What changes during rotation: + +- the active ``key_id`` can change +- the master may advertise rotation state in ``bootstrap_ack`` +- reconnect after rotation establishes a fresh secure session using the new + managed transport key id + +Related Material +---------------- + +- :doc:`secure_transport` for operator-facing usage +- :doc:`operator_security` for registration, key storage, and troubleshooting +- :doc:`security_model` for threat coverage and limits diff --git a/docs/index.rst b/docs/index.rst index 40c505f6..dfe754de 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,6 +48,7 @@ welcome—see the section on contributing for how to get involved. tutorial/action_chain_tutor tutorial/module_management tutorial/profiles_tutor + tutorial/secure_transport_tutor tutorial/wasm_modules_tutor tutorial/lua_modules_tutor tutorial/menotify_tutor diff --git a/docs/tutorial/secure_transport_tutor.rst b/docs/tutorial/secure_transport_tutor.rst new file mode 100644 index 00000000..aa779613 --- /dev/null +++ b/docs/tutorial/secure_transport_tutor.rst @@ -0,0 +1,161 @@ +Secure Transport Tutorial +========================= + +This tutorial walks through the normal operator lifecycle for secure transport: + +1. first registration +2. fingerprint verification +3. automatic key provisioning +4. Web API TLS usage +5. recovery from a broken or replaced master/minion + +First Bootstrap +--------------- + +Start ``sysmaster`` first. + +Then start the minion once. If this is the first contact, the minion will not +be registered yet and will print the master fingerprint. + +Typical pattern: + +.. code-block:: text + + ERROR: Minion is not registered + INFO: Master fingerprint: + +At this point: + +- do not trust the fingerprint blindly +- verify it through your normal out-of-band process + +Fingerprint Verification +------------------------ + +After you have verified the fingerprint, register the minion: + +.. code-block:: bash + + sysminion --register + +If registration succeeds, the master accepts the minion RSA identity and both +sides create managed transport metadata automatically. + +What gets provisioned automatically: + +- the minion stores the trusted master public key +- the master stores the minion public key +- the minion creates ``transport/master/state.json`` +- the master creates ``transport/minions//state.json`` + +Normal Startup After Registration +--------------------------------- + +Once registration exists, start the minion normally: + +.. code-block:: bash + + sysminion --start + +The normal sequence is: + +1. the minion loads its managed transport state +2. the minion sends secure bootstrap to the master +3. the master validates identity, version, and replay rules +4. the connection switches to a secure session +5. traits, commands, events, and sync control traffic use that secure session + +Verify it from the operator side: + +.. code-block:: bash + + sysinspect network --status + +Look for: + +- a current handshake timestamp +- an active key id +- idle rotation state unless you intentionally staged rotation + +Automatic Key Provisioning +-------------------------- + +You do not need to create transport session keys manually. + +Sysinspect manages: + +- registration trust anchors +- transport metadata +- fresh per-connection secure sessions +- staged and applied rotation state + +If you rotate transport state: + +.. code-block:: bash + + sysinspect network --rotate --id + +the reconnect and secure bootstrap after that rotation are still automatic. + +Web API TLS Usage +----------------- + +The Web API is separate from the Master/Minion secure transport. + +Configure it on the master: + +.. code-block:: yaml + + config: + master: + api.enabled: true + api.tls.enabled: true + api.tls.cert-file: etc/web/api.crt + api.tls.key-file: etc/web/api.key + +Then restart ``sysmaster`` and open: + +.. code-block:: text + + https://:4202/doc/ + +Normal API flow: + +1. authenticate over HTTPS +2. receive a bearer token +3. send plain JSON requests over HTTPS with ``Authorization: Bearer `` + +Broken Minion Recovery +---------------------- + +If a minion loses trust data or is rebuilt: + +1. start it once and inspect the failure +2. if needed, unregister the old relationship on the master +3. verify the current master fingerprint again +4. register the minion again +5. start it normally +6. verify secure handshake with ``sysinspect network --status`` + +Broken Master Or Replaced Master Recovery +----------------------------------------- + +If the master identity changes, the old trust relationship is no longer valid. + +Recovery flow: + +1. start the rebuilt master +2. verify its new fingerprint +3. re-register affected minions against the new master fingerprint +4. start the minions normally +5. verify transport status and, if desired, run a cluster sync + +Quick Checklist +--------------- + +For healthy secure operation: + +- verify the master fingerprint during registration +- avoid editing transport state files manually +- use ``network --status`` to confirm handshakes and rotation state +- keep Web API TLS configured separately from the Master/Minion transport From e85341790c1a18851ab23278b173c871dfb77778 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 16:12:31 +0100 Subject: [PATCH 33/43] Add proto examples --- examples/transport-fixtures/README.md | 20 +++++++++++++++++++ .../transport-fixtures/bootstrap_ack.json | 18 +++++++++++++++++ .../transport-fixtures/bootstrap_hello.json | 19 ++++++++++++++++++ .../transport-fixtures/secure_data_frame.json | 9 +++++++++ .../webapi_auth_request.json | 4 ++++ .../webapi_query_request.json | 9 +++++++++ 6 files changed, 79 insertions(+) create mode 100644 examples/transport-fixtures/README.md create mode 100644 examples/transport-fixtures/bootstrap_ack.json create mode 100644 examples/transport-fixtures/bootstrap_hello.json create mode 100644 examples/transport-fixtures/secure_data_frame.json create mode 100644 examples/transport-fixtures/webapi_auth_request.json create mode 100644 examples/transport-fixtures/webapi_query_request.json diff --git a/examples/transport-fixtures/README.md b/examples/transport-fixtures/README.md new file mode 100644 index 00000000..88a028a5 --- /dev/null +++ b/examples/transport-fixtures/README.md @@ -0,0 +1,20 @@ +# Secure transport fixtures + +These files are minimal example fixtures for the current transport and Web API +shapes. + +They are illustrative fixtures, not captured live traffic dumps. + +Files: + +- `bootstrap_hello.json`: plaintext secure bootstrap opening +- `bootstrap_ack.json`: plaintext secure bootstrap acknowledgement +- `secure_data_frame.json`: encrypted steady-state frame envelope +- `webapi_auth_request.json`: HTTPS JSON authentication request body +- `webapi_query_request.json`: HTTPS JSON query request body + +Use them for: + +- documentation examples +- fixture-driven tests +- quick contract reviews during API or protocol changes diff --git a/examples/transport-fixtures/bootstrap_ack.json b/examples/transport-fixtures/bootstrap_ack.json new file mode 100644 index 00000000..2ffff5c7 --- /dev/null +++ b/examples/transport-fixtures/bootstrap_ack.json @@ -0,0 +1,18 @@ +{ + "kind": "bootstrap_ack", + "binding": { + "minion_id": "minion-a", + "minion_rsa_fingerprint": "minion-fp", + "master_rsa_fingerprint": "master-fp", + "protocol_version": 1, + "connection_id": "conn-1", + "client_nonce": "client-nonce", + "master_nonce": "master-nonce", + "timestamp": 1734739200 + }, + "session_id": "sid-1", + "key_id": "trk-current", + "rotation": "none", + "master_ephemeral_pubkey": "", + "binding_signature": "" +} diff --git a/examples/transport-fixtures/bootstrap_hello.json b/examples/transport-fixtures/bootstrap_hello.json new file mode 100644 index 00000000..29851ae0 --- /dev/null +++ b/examples/transport-fixtures/bootstrap_hello.json @@ -0,0 +1,19 @@ +{ + "kind": "bootstrap_hello", + "binding": { + "minion_id": "minion-a", + "minion_rsa_fingerprint": "minion-fp", + "master_rsa_fingerprint": "master-fp", + "protocol_version": 1, + "connection_id": "conn-1", + "client_nonce": "client-nonce", + "master_nonce": "", + "timestamp": 1734739200 + }, + "supported_versions": [ + 1 + ], + "client_ephemeral_pubkey": "", + "binding_signature": "", + "key_id": "trk-current" +} diff --git a/examples/transport-fixtures/secure_data_frame.json b/examples/transport-fixtures/secure_data_frame.json new file mode 100644 index 00000000..57f162dc --- /dev/null +++ b/examples/transport-fixtures/secure_data_frame.json @@ -0,0 +1,9 @@ +{ + "kind": "data", + "protocol_version": 1, + "session_id": "sid-1", + "key_id": "trk-current", + "counter": 1, + "nonce": "", + "payload": "" +} diff --git a/examples/transport-fixtures/webapi_auth_request.json b/examples/transport-fixtures/webapi_auth_request.json new file mode 100644 index 00000000..7cf3c28d --- /dev/null +++ b/examples/transport-fixtures/webapi_auth_request.json @@ -0,0 +1,4 @@ +{ + "username": "operator", + "password": "secret" +} diff --git a/examples/transport-fixtures/webapi_query_request.json b/examples/transport-fixtures/webapi_query_request.json new file mode 100644 index 00000000..d345d3fe --- /dev/null +++ b/examples/transport-fixtures/webapi_query_request.json @@ -0,0 +1,9 @@ +{ + "model": "cm/file-ops", + "query": "*", + "traits": "", + "mid": "", + "context": { + "reason": "manual-run" + } +} From 24b5ed17539f86594c875827bacf91b765f41b5b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 16:12:47 +0100 Subject: [PATCH 34/43] Update proto README guide --- libsysproto/src/README.md | 105 +++++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/libsysproto/src/README.md b/libsysproto/src/README.md index 1e55a737..29a62709 100644 --- a/libsysproto/src/README.md +++ b/libsysproto/src/README.md @@ -4,7 +4,7 @@ Protocol description about message exchange between master and a minion. ## Secure transport design -This document is the Phase 1 source of truth for the secure Master/Minion transport. +This document is the source of truth for the secure Master/Minion transport. The concrete shared protocol types live in `libsysproto::secure`. ### Transport goals @@ -65,6 +65,28 @@ Fields: - `binding_signature`: minion RSA signature over the binding and ephemeral public key - `key_id`: optional transport key identifier for reconnect or rotation continuity +Example: + +```json +{ + "kind": "bootstrap_hello", + "binding": { + "minion_id": "minion-a", + "minion_rsa_fingerprint": "minion-fp", + "master_rsa_fingerprint": "master-fp", + "protocol_version": 1, + "connection_id": "conn-1", + "client_nonce": "client-nonce", + "master_nonce": "", + "timestamp": 1734739200 + }, + "supported_versions": [1], + "client_ephemeral_pubkey": "", + "binding_signature": "", + "key_id": "trk-current" +} +``` + #### `bootstrap_ack` Sent by the master after validating the registered minion RSA identity and accepting the new secure session. @@ -78,6 +100,29 @@ Fields: - `master_ephemeral_pubkey`: master ephemeral Curve25519 public key - `binding_signature`: master RSA signature over the completed binding, accepted session id, and master ephemeral public key +Example: + +```json +{ + "kind": "bootstrap_ack", + "binding": { + "minion_id": "minion-a", + "minion_rsa_fingerprint": "minion-fp", + "master_rsa_fingerprint": "master-fp", + "protocol_version": 1, + "connection_id": "conn-1", + "client_nonce": "client-nonce", + "master_nonce": "master-nonce", + "timestamp": 1734739200 + }, + "session_id": "sid-1", + "key_id": "trk-current", + "rotation": "none", + "master_ephemeral_pubkey": "", + "binding_signature": "" +} +``` + #### `bootstrap_diagnostic` Plaintext rejection or negotiation failure emitted before a secure session exists. @@ -88,6 +133,21 @@ Fields: - `message`: human-readable diagnostic - `failure`: retry and disconnect semantics +Example: + +```json +{ + "kind": "bootstrap_diagnostic", + "code": "replay_rejected", + "message": "Secure bootstrap replay rejected for minion-a", + "failure": { + "retryable": false, + "disconnect": true, + "rate_limit": true + } +} +``` + ### Encrypted steady-state frame After bootstrap succeeds, every Master/Minion frame must use `data`. @@ -104,6 +164,49 @@ Fields: - `nonce`: libsodium nonce for the sealed payload - `payload`: authenticated encrypted payload +Example: + +```json +{ + "kind": "data", + "protocol_version": 1, + "session_id": "sid-1", + "key_id": "trk-current", + "counter": 1, + "nonce": "", + "payload": "" +} +``` + +### Handshake sequence + +The exact bootstrap sequence is: + +1. the minion opens a TCP connection to the master +2. the minion sends `bootstrap_hello` +3. the master checks: + - registered minion identity + - RSA fingerprints against managed transport state + - supported protocol versions + - replay window and duplicate-session rules +4. the master sends either: + - `bootstrap_ack` on success + - `bootstrap_diagnostic` on failure +5. both sides derive the same short-lived session key from: + - the authenticated Curve25519 shared secret + - the full `SecureSessionBinding` + - both ephemeral public keys +6. every later frame on that TCP connection must use `data` + +### Steady-state frame rules + +For `data` frames: + +- `counter` must increase exactly by one in each direction +- `nonce` must match the counter-derived nonce expected for that session +- `session_id` and `key_id` must match the active secure channel +- replayed, stale, duplicated, or tampered frames are rejected + ### Failure semantics Unsupported or malformed peers must not silently fall back to plaintext behavior. From 02463f9dbda9fb84ba6b4dd62c8e7561e3090f7f Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 19:57:48 +0100 Subject: [PATCH 35/43] Update docs --- docs/genusage/operator_security.rst | 3 +++ docs/global_config.rst | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/genusage/operator_security.rst b/docs/genusage/operator_security.rst index db0a7fc9..900b0d34 100644 --- a/docs/genusage/operator_security.rst +++ b/docs/genusage/operator_security.rst @@ -156,6 +156,9 @@ Optional configuration: api.tls.ca-file: trust/ca.pem api.tls.allow-insecure: false +If ``api.tls.ca-file`` is set, the Web API requires client certificates signed +by that CA bundle. + Behavior: - if ``api.enabled`` is ``true`` but TLS is not configured correctly, the Web diff --git a/docs/global_config.rst b/docs/global_config.rst index 1a8e2b2b..694df79b 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -384,11 +384,15 @@ Below are directives for the configuration of the File Server service: Type: **string** - Optional CA bundle path used for TLS validation or mutual TLS extensions. + Optional CA bundle path used to verify client certificates for the Web API + TLS listener. If the path is relative, it is resolved under the Sysinspect root. If it is absolute, it is used as-is. + When set, clients must present a certificate chain that validates against + this CA bundle. + ``api.tls.allow-insecure`` ########################## From 04da69e6c2030db45c4261b537aa2e68070c02bb Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 19:58:14 +0100 Subject: [PATCH 36/43] Address review findings --- libsysinspect/src/cfg/mmconf.rs | 2 +- libsysinspect/src/transport/secure_channel.rs | 26 +++- libwebapi/Cargo.toml | 2 +- libwebapi/src/api/v1/model.rs | 23 +++- libwebapi/src/api/v1/store.rs | 35 ++++- libwebapi/src/lib.rs | 24 +++- libwebapi/tests/api_contract.rs | 129 ++++++++++++++++-- libwebapi/tests/data/webapi-test-ca.crt | 19 +++ libwebapi/tests/data/webapi-test-client.crt | 20 +++ libwebapi/tests/data/webapi-test-client.key | 28 ++++ libwebapi/tests/data/webapi-test-server.crt | 20 +++ libwebapi/tests/data/webapi-test-server.key | 28 ++++ 12 files changed, 320 insertions(+), 36 deletions(-) create mode 100644 libwebapi/tests/data/webapi-test-ca.crt create mode 100644 libwebapi/tests/data/webapi-test-client.crt create mode 100644 libwebapi/tests/data/webapi-test-client.key create mode 100644 libwebapi/tests/data/webapi-test-server.crt create mode 100644 libwebapi/tests/data/webapi-test-server.key diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index a4efa61a..53ab2212 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -837,7 +837,7 @@ pub struct MasterConfig { #[serde(rename = "api.tls.key-file")] api_tls_key_file: Option, - /// Optional CA bundle path used for TLS validation or mutual TLS extensions. + /// Optional CA bundle path used to verify client certificates for the Web API TLS listener. #[serde(rename = "api.tls.ca-file")] api_tls_ca_file: Option, diff --git a/libsysinspect/src/transport/secure_channel.rs b/libsysinspect/src/transport/secure_channel.rs index 3960c493..dc4dcab5 100644 --- a/libsysinspect/src/transport/secure_channel.rs +++ b/libsysinspect/src/transport/secure_channel.rs @@ -29,6 +29,7 @@ pub struct SecureChannel { session_id: String, key_id: String, key: Key, + role: SecurePeerRole, tx_counter: u64, rx_counter: u64, base_nonce: [u8; secretbox::NONCEBYTES], @@ -36,7 +37,7 @@ pub struct SecureChannel { impl SecureChannel { /// Create a steady-state secure channel from an accepted bootstrap session. - pub fn new(_role: SecurePeerRole, bootstrap: &SecureBootstrapSession) -> Result { + pub fn new(role: SecurePeerRole, bootstrap: &SecureBootstrapSession) -> Result { sodium_ready()?; let session_id = bootstrap .session_id() @@ -57,6 +58,7 @@ impl SecureChannel { session_id, key_id: bootstrap.key_id().to_string(), key: bootstrap.session_key().clone(), + role, tx_counter: 0, rx_counter: 0, base_nonce, @@ -88,13 +90,14 @@ impl SecureChannel { } self.tx_counter = self.tx_counter.checked_add(1).ok_or_else(|| SysinspectError::ProtoError("Secure transmit counter overflow".to_string()))?; + let nonce = Self::nonce(self.role, self.tx_counter, &self.base_nonce); serde_json::to_vec(&SecureFrame::Data(SecureDataFrame { protocol_version: SECURE_PROTOCOL_VERSION, session_id: self.session_id.clone(), key_id: self.key_id.clone(), counter: self.tx_counter, - nonce: STANDARD.encode(Self::nonce(self.tx_counter, &self.base_nonce).0), - payload: STANDARD.encode(secretbox::seal(payload, &Self::nonce(self.tx_counter, &self.base_nonce), &self.key)), + nonce: STANDARD.encode(nonce.0), + payload: STANDARD.encode(secretbox::seal(payload, &nonce, &self.key)), })) .map_err(|err| SysinspectError::SerializationError(format!("Failed to encode secure data frame: {err}"))) } @@ -145,7 +148,7 @@ impl SecureChannel { if frame.counter != self.rx_counter.saturating_add(1) { return Err(SysinspectError::ProtoError(format!("Secure frame counter {} is out of sequence after {}", frame.counter, self.rx_counter))); } - let expected_nonce = Self::nonce(frame.counter, &self.base_nonce); + let expected_nonce = Self::nonce(Self::opposite_role(self.role), frame.counter, &self.base_nonce); if STANDARD.encode(expected_nonce.0) != frame.nonce { return Err(SysinspectError::ProtoError("Secure data frame nonce does not match the expected counter-derived nonce".to_string())); } @@ -162,15 +165,26 @@ impl SecureChannel { Ok(payload) } - /// Derive a deterministic nonce from the base_nonce and monotonic counter. - fn nonce(counter: u64, base_nonce: &[u8; secretbox::NONCEBYTES]) -> Nonce { + /// Derive a deterministic per-direction nonce from the base nonce and monotonic counter. + fn nonce(role: SecurePeerRole, counter: u64, base_nonce: &[u8; secretbox::NONCEBYTES]) -> Nonce { let mut nonce = *base_nonce; + nonce[0] ^= match role { + SecurePeerRole::Master => 0x4d, + SecurePeerRole::Minion => 0x6d, + }; let counter_bytes = counter.to_be_bytes(); for i in 0..8 { nonce[secretbox::NONCEBYTES - 8 + i] ^= counter_bytes[i]; } Nonce(nonce) } + + fn opposite_role(role: SecurePeerRole) -> SecurePeerRole { + match role { + SecurePeerRole::Master => SecurePeerRole::Minion, + SecurePeerRole::Minion => SecurePeerRole::Master, + } + } } fn sodium_ready() -> Result<(), SysinspectError> { diff --git a/libwebapi/Cargo.toml b/libwebapi/Cargo.toml index b2aa2c5c..9d9e1c76 100644 --- a/libwebapi/Cargo.toml +++ b/libwebapi/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] actix-web = { version = "4.12.1", features = ["rustls-0_23"] } -reqwest = { version = "0.12.28", features = ["blocking", "json"] } +reqwest = { version = "0.12.28", features = ["blocking", "json", "native-tls"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" tokio = { version = "1.49.0", features = ["full"] } diff --git a/libwebapi/src/api/v1/model.rs b/libwebapi/src/api/v1/model.rs index ad4ff39a..04e0f9c8 100644 --- a/libwebapi/src/api/v1/model.rs +++ b/libwebapi/src/api/v1/model.rs @@ -1,7 +1,7 @@ use crate::{MasterInterfaceType, api::v1::{TAG_MODELS, minions::authorize_request}}; use actix_web::{ HttpRequest, HttpResponse, Result, get, - web::{Data, Json, Query}, + web::{Data, Query}, }; use indexmap::IndexMap; use libcommon::SysinspectError; @@ -96,15 +96,18 @@ pub struct ModelNameResponse { ("bearer_auth" = []) ), responses( - (status = 200, description = "List of available models", body = ModelNameResponse) + (status = 200, description = "List of available models", body = ModelNameResponse), + (status = 401, description = "Unauthorized", body = ModelResponseError) ) )] #[allow(unused)] #[get("/api/v1/model/names")] -pub async fn model_names_handler(req: HttpRequest, master: Data) -> Result> { - authorize_request(&req).map_err(actix_web::error::ErrorUnauthorized)?; +pub async fn model_names_handler(req: HttpRequest, master: Data) -> Result { + if let Err(err) = authorize_request(&req) { + return Ok(HttpResponse::Unauthorized().json(ModelResponseError { error: err.to_string() })); + } let mut master = master.lock().await; - Ok(Json(ModelNameResponse { models: master.cfg().await.fileserver_models().to_owned() })) + Ok(HttpResponse::Ok().json(ModelNameResponse { models: master.cfg().await.fileserver_models().to_owned() })) } #[utoipa::path( get, @@ -119,13 +122,19 @@ pub async fn model_names_handler(req: HttpRequest, master: Data, query: Query>) -> Result { - authorize_request(&req).map_err(actix_web::error::ErrorUnauthorized)?; + if let Err(err) = authorize_request(&req) { + return Ok(HttpResponse::Unauthorized().json(ModelResponseError { error: err.to_string() })); + } let mid = query.get("name").cloned().unwrap_or_default(); // Model Id if mid.is_empty() { return Ok(HttpResponse::BadRequest().json(ModelResponseError { error: "Missing 'name' query parameter".to_string() })); diff --git a/libwebapi/src/api/v1/store.rs b/libwebapi/src/api/v1/store.rs index 376b82f3..3a9b4f17 100644 --- a/libwebapi/src/api/v1/store.rs +++ b/libwebapi/src/api/v1/store.rs @@ -30,6 +30,20 @@ pub struct StoreListQuery { pub limit: Option, } +#[derive(Debug, Serialize, ToSchema)] +pub struct StoreErrorResponse { + pub error: String, +} + +fn unauthorized_store_error(err: libcommon::SysinspectError) -> actix_web::Error { + let msg = err.to_string(); + actix_web::error::InternalError::from_response( + err, + HttpResponse::Unauthorized().json(StoreErrorResponse { error: msg }), + ) + .into() +} + /// Get a list of all meta files within the datastore. fn get_meta_files(root: &Path, out: &mut Vec) -> std::io::Result<()> { if !root.exists() { @@ -63,6 +77,7 @@ fn get_meta_files(root: &Path, out: &mut Vec) -> std::io::Result<()> { ), responses( (status = 200, description = "Metadata for object", body = StoreMetaResponse), + (status = 401, description = "Unauthorized", body = StoreErrorResponse), (status = 404, description = "Not found"), (status = 500, description = "Datastore error") ) @@ -70,7 +85,7 @@ fn get_meta_files(root: &Path, out: &mut Vec) -> std::io::Result<()> { #[get("/store/{sha256:[0-9a-fA-F]{64}}")] pub async fn store_meta_handler(req: HttpRequest, master: web::Data, sha256: web::Path) -> impl Responder { if let Err(err) = authorize_request(&req) { - return HttpResponse::Unauthorized().body(err.to_string()); + return HttpResponse::Unauthorized().json(StoreErrorResponse { error: err.to_string() }); } let ds = { let m = master.lock().await; @@ -104,6 +119,7 @@ pub async fn store_meta_handler(req: HttpRequest, master: web::Data, sha256: web::Path) -> ActixResult { - authorize_request(&req).map_err(actix_web::error::ErrorUnauthorized)?; + authorize_request(&req).map_err(unauthorized_store_error)?; let ds = { let m = master.lock().await; m.datastore().await @@ -140,6 +156,7 @@ pub async fn store_blob_handler(req: HttpRequest, master: web::Data, mut payload: web::Payload) -> impl Responder { if let Err(err) = authorize_request(&req) { - return HttpResponse::Unauthorized().body(err.to_string()); + return HttpResponse::Unauthorized().json(StoreErrorResponse { error: err.to_string() }); } // full path goes into fname (as you demanded) let origin = req.headers().get("X-Filename").and_then(|v| v.to_str().ok()).map(|s| s.to_string()); @@ -247,6 +264,7 @@ pub async fn store_upload_handler(req: actix_web::HttpRequest, master: web::Data ), responses( (status = 200, description = "Resolved metadata", body = StoreMetaResponse), + (status = 401, description = "Unauthorized", body = StoreErrorResponse), (status = 404, description = "Not found"), (status = 500, description = "Error") ) @@ -254,7 +272,7 @@ pub async fn store_upload_handler(req: actix_web::HttpRequest, master: web::Data #[get("/store/resolve")] pub async fn store_resolve_handler(req: HttpRequest, master: web::Data, q: web::Query) -> impl Responder { if let Err(err) = authorize_request(&req) { - return HttpResponse::Unauthorized().body(err.to_string()); + return HttpResponse::Unauthorized().json(StoreErrorResponse { error: err.to_string() }); } let (root, want) = { let m = master.lock().await; @@ -319,17 +337,24 @@ pub async fn store_resolve_handler(req: HttpRequest, master: web::Data, Query, description = "Only return items where meta.fname starts with this prefix"), ("limit" = Option, Query, description = "Max items to return (default 200)") ), responses( (status = 200, description = "List of metadata", body = Vec), + (status = 401, description = "Unauthorized", body = StoreErrorResponse), (status = 500, description = "Error") ) )] #[get("/store/list")] -pub async fn store_list_handler(master: web::Data, q: web::Query) -> impl Responder { +pub async fn store_list_handler(req: HttpRequest, master: web::Data, q: web::Query) -> impl Responder { + if let Err(err) = authorize_request(&req) { + return HttpResponse::Unauthorized().json(StoreErrorResponse { error: err.to_string() }); + } let (root, prefix, limit) = { let m = master.lock().await; (m.cfg().await.datastore_path(), q.prefix.clone(), q.limit.unwrap_or(200).min(5000)) diff --git a/libwebapi/src/lib.rs b/libwebapi/src/lib.rs index 7533dc40..bdfd1055 100644 --- a/libwebapi/src/lib.rs +++ b/libwebapi/src/lib.rs @@ -5,6 +5,8 @@ use libcommon::SysinspectError; use libdatastore::resources::DataStorage; use libsysinspect::cfg::mmconf::MasterConfig; use rustls::ServerConfig; +use rustls::RootCertStore; +use rustls::server::WebPkiClientVerifier; use std::{fs::File, io::BufReader, sync::Arc, thread}; use tokio::sync::Mutex; @@ -54,7 +56,7 @@ pub(crate) fn tls_setup_err_message() -> String { /// Loads the TLS server configuration for the Web API from the provided MasterConfig. /// This includes reading the certificate and private key files, and optionally -/// the CA file if client certificate authentication is configured. +/// the CA file for client certificate authentication. /// Returns a ServerConfig on success, or a SysinspectError with a user-friendly message on failure. fn load_tls_server_config(cfg: &MasterConfig) -> Result { let cert_path = cfg @@ -88,7 +90,7 @@ fn load_tls_server_config(cfg: &MasterConfig) -> Result Result MasterConfig { MasterConfig::new(cfg_path).unwrap() } -fn tls_config() -> ServerConfig { - let mut cert_reader = BufReader::new(CERT_PEM.as_bytes()); +fn tls_config(require_client_auth: bool) -> ServerConfig { + let (server_cert_pem, server_key_pem, ca_cert_pem) = if require_client_auth { + (MTLS_SERVER_CERT_PEM, MTLS_SERVER_KEY_PEM, Some(MTLS_CA_CERT_PEM)) + } else { + (CERT_PEM, KEY_PEM, None) + }; + + let mut cert_reader = BufReader::new(server_cert_pem.as_bytes()); let certs = rustls_pemfile::certs(&mut cert_reader).collect::, _>>().unwrap(); - let mut key_reader = BufReader::new(KEY_PEM.as_bytes()); + let mut key_reader = BufReader::new(server_key_pem.as_bytes()); let key = rustls_pemfile::private_key(&mut key_reader).unwrap().unwrap(); - ServerConfig::builder().with_no_client_auth().with_single_cert(certs, key).unwrap() + let builder = if let Some(ca_cert_pem) = ca_cert_pem { + let mut ca_reader = BufReader::new(ca_cert_pem.as_bytes()); + let ca_certs = rustls_pemfile::certs(&mut ca_reader).collect::, _>>().unwrap(); + let mut roots = RootCertStore::empty(); + for ca_cert in ca_certs { + roots.add(ca_cert).unwrap(); + } + let verifier = WebPkiClientVerifier::builder(Arc::new(roots)).build().unwrap(); + ServerConfig::builder().with_client_cert_verifier(verifier) + } else { + ServerConfig::builder().with_no_client_auth() + }; + + builder.with_single_cert(certs, key).unwrap() } -async fn spawn_https_server(devmode: bool) -> (String, Arc>>, JoinHandle>) { +async fn spawn_https_server(devmode: bool, require_client_auth: bool) -> (String, Arc>>, JoinHandle>) { let root = tempfile::tempdir().unwrap(); let cfg = write_cfg(root.path(), devmode); let queries = Arc::new(Mutex::new(Vec::new())); @@ -66,7 +92,7 @@ async fn spawn_https_server(devmode: bool) -> (String, Arc>>, let scope = api::get(devmode, ApiVersions::V1).unwrap().load(web::scope("")); App::new().app_data(web::Data::new(master.clone())).service(scope) }) - .bind_rustls_0_23(("127.0.0.1", 0), tls_config()) + .bind_rustls_0_23(("127.0.0.1", 0), tls_config(require_client_auth)) .unwrap(); let addr = server.addrs()[0]; let handle = tokio::spawn(server.run()); @@ -82,9 +108,24 @@ fn trusted_client() -> reqwest::Client { .unwrap() } +fn trusted_mtls_client() -> reqwest::Client { + reqwest::Client::builder() + .add_root_certificate(Certificate::from_pem(MTLS_CA_CERT_PEM.as_bytes()).unwrap()) + .build() + .unwrap() +} + +fn trusted_client_with_identity() -> reqwest::Client { + reqwest::Client::builder() + .add_root_certificate(Certificate::from_pem(MTLS_CA_CERT_PEM.as_bytes()).unwrap()) + .identity(Identity::from_pkcs8_pem(MTLS_CLIENT_CERT_PEM.as_bytes(), MTLS_CLIENT_KEY_PEM.as_bytes()).unwrap()) + .build() + .unwrap() +} + #[tokio::test] async fn https_server_rejects_default_certificate_validation_for_self_signed_cert() { - let (base, _, handle) = spawn_https_server(true).await; + let (base, _, handle) = spawn_https_server(true, false).await; let err = reqwest::Client::new() .post(format!("{base}/api/v1/health")) @@ -99,7 +140,7 @@ async fn https_server_rejects_default_certificate_validation_for_self_signed_cer #[tokio::test] async fn https_auth_and_query_use_plain_json_and_bearer_token() { - let (base, queries, handle) = spawn_https_server(true).await; + let (base, queries, handle) = spawn_https_server(true, false).await; let client = trusted_client(); let auth = client @@ -141,7 +182,7 @@ async fn https_auth_and_query_use_plain_json_and_bearer_token() { #[tokio::test] async fn https_query_rejects_missing_bearer_token() { - let (base, _, handle) = spawn_https_server(true).await; + let (base, _, handle) = spawn_https_server(true, false).await; let client = trusted_client(); let response = client @@ -163,7 +204,7 @@ async fn https_query_rejects_missing_bearer_token() { #[tokio::test] async fn https_model_names_returns_plain_json_list() { - let (base, _, handle) = spawn_https_server(true).await; + let (base, _, handle) = spawn_https_server(true, false).await; let client = trusted_client(); let auth = client .post(format!("{base}/api/v1/authenticate")) @@ -190,3 +231,69 @@ async fn https_model_names_returns_plain_json_list() { assert_eq!(response["models"], serde_json::json!(["cm", "net"])); handle.abort(); } + +#[tokio::test] +async fn https_model_names_rejects_missing_bearer_token_with_json_error() { + let (base, _, handle) = spawn_https_server(true, false).await; + let client = trusted_client(); + + let response = client + .get(format!("{base}/api/v1/model/names")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + let body = response.json::().await.unwrap(); + assert_eq!(body["error"], "Error Web API: Missing Authorization header"); + handle.abort(); +} + +#[tokio::test] +async fn https_store_list_rejects_missing_bearer_token_with_json_error() { + let (base, _, handle) = spawn_https_server(true, false).await; + let client = trusted_client(); + + let response = client + .get(format!("{base}/store/list")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + let body = response.json::().await.unwrap(); + assert_eq!(body["error"], "Error Web API: Missing Authorization header"); + handle.abort(); +} + +#[tokio::test] +async fn https_server_rejects_requests_without_required_client_certificate() { + let (base, _, handle) = spawn_https_server(true, true).await; + let err = trusted_mtls_client() + .post(format!("{base}/api/v1/health")) + .send() + .await + .unwrap_err() + .to_string(); + + assert!(!err.is_empty()); + handle.abort(); +} + +#[tokio::test] +async fn https_server_accepts_requests_with_trusted_client_certificate() { + let (base, _, handle) = spawn_https_server(true, true).await; + let response = trusted_client_with_identity() + .post(format!("{base}/api/v1/health")) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(response["status"], "healthy"); + handle.abort(); +} diff --git a/libwebapi/tests/data/webapi-test-ca.crt b/libwebapi/tests/data/webapi-test-ca.crt new file mode 100644 index 00000000..d9104dff --- /dev/null +++ b/libwebapi/tests/data/webapi-test-ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKzCCAhOgAwIBAgIUJMv/3xiuChSAX5zbTkcCqdsP/ycwDQYJKoZIhvcNAQEL +BQAwHTEbMBkGA1UEAwwSU3lzaW5zcGVjdCBUZXN0IENBMB4XDTI2MDMyMTE4Mzc1 +M1oXDTI3MDMyMTE4Mzc1M1owHTEbMBkGA1UEAwwSU3lzaW5zcGVjdCBUZXN0IENB +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwo5T0+qqvi/hwn9634ia +QfD19nuZoeFk2mfewzZc4Icwa1oMjtIjMcGr1yTfJQYi+AC258Dtzdpr5B90FZW5 +O7Fb7LuxLDYYB+3MAmJxevu9ZM95y8XDuJ/A4B2NLd0owH+yqKS8irxdAGptw9jJ +YV5mS7J7/i+5JWSV3YkKoJEc6otOhfkTe8pj59h+Pb9frK8fQIgY8TlXqs45IEFw +7IKiQ9+Xm3R12D6ENnn1GKbEfX7igQc1qsdx9p/aqWoKbriyRNbcLjtclqEOsj4l +JeCOSTXLAgCHIJtZRugNVOtS4AB9Lw9qBWcgKQd2DKxJgMehWzBLZpZ2dupYjCjO +4QIDAQABo2MwYTAdBgNVHQ4EFgQUVRO26TCVbDvH9xyONLuTjYo6UYUwHwYDVR0j +BBgwFoAUVRO26TCVbDvH9xyONLuTjYo6UYUwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggEBAHlSgDvnaB2Wynh3v8nSphOa +rmoqExBVOWFrM7VVRK4bKEocbH7wO14oD+wkPckIaR8X72jmlBP8XAj0RDJuT9mf +RL+cYlo1TOuF5nPDGA6+qwFsVJUCY7UykIMRiiDnU27qpJ6gRIVeTZf3K0ywgJC4 +0VHhA1go7RMXiqbVyAEpVqLxvKvzdQasMg1CoD4rCiKqwPaHr+c2vSccVu8RO4oP +jP9UuUND8ilAjVyYf1JvQ9NbGpGYFamvoyaAIFsYF/lXecfbT2UMkvr4NhGeE5l/ +No9Zs+c5RAjgjh9pYmZHgRWcHJ8XJSiH0okwPCnsFt3OvYVrUbBNCjpdCGrpy5o= +-----END CERTIFICATE----- diff --git a/libwebapi/tests/data/webapi-test-client.crt b/libwebapi/tests/data/webapi-test-client.crt new file mode 100644 index 00000000..581e9405 --- /dev/null +++ b/libwebapi/tests/data/webapi-test-client.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDOjCCAiKgAwIBAgIUDCkZ4lZ3QWAUa2uLYq397hC/ookwDQYJKoZIhvcNAQEL +BQAwHTEbMBkGA1UEAwwSU3lzaW5zcGVjdCBUZXN0IENBMB4XDTI2MDMyMTE4Mzc1 +M1oXDTI3MDMyMTE4Mzc1M1owHTEbMBkGA1UEAwwSd2ViYXBpLXRlc3QtY2xpZW50 +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyGfEFvClTxkQQhHIWNNv +mlMfVNqBfYlkDk1ojQg5e7IR25xcBoQL2WhthH4TJbKhCw0H0WzE0egEVrignxlK +yujNqiIeM6yfW8eNxt8Ql9/0LJA9YIW0hTRZ2lqWbFgb+3q7z+WE/OoLmcRLJhwX +VM3I2nlOupdNUniQ08wwll9bbo8ouREP5knpxU8uK34WjbrZ3Q6C+uNKb1m+I0Y9 +ZQiRwu3Wbris3PLQEFMyfDp52DBAFkSgu8kRxrM8zNtXdXZbTW2c3CvxtW6xejO8 +ghJ64zPWRIJgfbQuVSUGqMSswnWpADzn6mZkF+aESKSEMpAgHJ+J04yYjqd7uUm9 +4QIDAQABo3IwcDAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAK +BggrBgEFBQcDAjAdBgNVHQ4EFgQUTRIN0g6fzPe6qGlvV2YRgvZC+dswHwYDVR0j +BBgwFoAUVRO26TCVbDvH9xyONLuTjYo6UYUwDQYJKoZIhvcNAQELBQADggEBACnE +3A5Kg0YsAqjmFLSdF0RIjuVX1X6OoC8SyAF53mnXe3x7jmflQdmzRMnbnplCju8F +7mL6R48lW///wKzJxoDYYpwr90NQPpzmmdf1aC6eV7F2vQ56fnQZiKdmraVsVjuK +Ud/euvNMtDN4gy9ATOQKQ98eWA3+fJ7AOOasQT0fIsxP1yF/ewdEwGMDxlHJUl0S +gOLRF+dcvkktLywJXw+xoGCtLnxYnOHR81b/zAcXkn2JNXA8N2Dkhu/XLQE+hNr1 +VS5zb2qNmz1LJ3+w1puYRKnfLA8gVuHbAFfPHrioRrxcfos8nGeyu3eyOh/0jj5m +p6nLM/Fz3f40SAlP3Ec= +-----END CERTIFICATE----- diff --git a/libwebapi/tests/data/webapi-test-client.key b/libwebapi/tests/data/webapi-test-client.key new file mode 100644 index 00000000..6208f88b --- /dev/null +++ b/libwebapi/tests/data/webapi-test-client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDIZ8QW8KVPGRBC +EchY02+aUx9U2oF9iWQOTWiNCDl7shHbnFwGhAvZaG2EfhMlsqELDQfRbMTR6ARW +uKCfGUrK6M2qIh4zrJ9bx43G3xCX3/QskD1ghbSFNFnaWpZsWBv7ervP5YT86guZ +xEsmHBdUzcjaeU66l01SeJDTzDCWX1tujyi5EQ/mSenFTy4rfhaNutndDoL640pv +Wb4jRj1lCJHC7dZuuKzc8tAQUzJ8OnnYMEAWRKC7yRHGszzM21d1dltNbZzcK/G1 +brF6M7yCEnrjM9ZEgmB9tC5VJQaoxKzCdakAPOfqZmQX5oRIpIQykCAcn4nTjJiO +p3u5Sb3hAgMBAAECggEAB0W+t69lbFSmQqOHD/QiS2kjTK6+Pro+44b7GY0YGu1I +GR5YN5NQo8PWn5V8p+RO1EoVg8vM66oeCDCcgZGHJYxjtD4XNvxXbxrzgelD3qMN +pxVX6NoJRkEzVolthoJ/B3X5fU6gsBXlNGALcxdXYGg0VvtKeFp3v5uo88qn47kD +JI6Sc8DiKEkKLkBZeWH/NZFz8U37rfylrjeOk8zXJ0xjbFS/LUNYYd3OyhYofzH3 +kteIs2+hQbsQsl/cd84kbH33xzM/KzdslMvqbCrlDaYGs3g3CJP3a8nGrHXCUgl+ +4VRp8nr0HBD/lE/RDDIpZDMefcKAXl2Iz7jcBV/HLQKBgQDO9uKdJx9xm3g5Uz23 +ZkWt8yvpFnZy5JxYT/7TteTbHVgY2bOQtWcz5oEn/Z/VqOF/mIzdyVESWQyiKEZU +leECDIpuIU6/RhUq/L1FWeTx6CJIm4JnOY1INKF0MEFpEVJbzoCQhiJgrWmXAuHQ +jgxyCxGnhvJwTtvdlM6hKmm7/QKBgQD34w0EvXnSewWFybZtn2oFkOFJH0yUmQb2 +Hq8Ih1S9riIeXEB847MyKke2j/Ksk8nrR6xChcA3j3Qim3xlydEb4zb6UEI9ePY1 +mJbPqMpy0IEZVZ1pd7rVaYhwHVyFeYq0wYyZXOqXRlc7VAAPydtPmyCj7HalVTsI +Eu+stE1ktQKBgAEUfL5BNALNwuTZsFrCp95uhG4k9d1HoCE92aCVNGqITqtih3Nb +3vwAWfAxfKIKzZJy41lM8aVc3ZoDB8rtNU1jb11/wv9wiC+/PeWcwHsasQMb/KQ5 +Qql7zNPkZJL9yiY8f6NBb/B9Ny3YkAEcnKgDssXjCGTZpIAVhLaGmCKpAoGBAJ4q +KSxVGV3LUQLEaboYdTWH87cMWXiXC3IOse/nKZK9gNeOVTdasgPYJlm+D0E+KyAM +Y0Uuwi6xQZCzVPQ9iUcZ+wJMI3fFrpMUAWYOdN49W6ImloGs+3EgHQYsNdSUcIRU +2rkgKC7Nmusn9cIdMenhOTpernVfpILKUlMH2DnhAoGAKZZp/3gnu9Xr6iMs6e7W +iCB4lHzCTHYub4GnMqdp5NeKyo2MMo8hUXNH8CuoiDOE67h21fN1Vvd4gDpgOjXx +VY41xDG0Kpbbm8G1m/4BMRNmixjOwTn8+tugnpYSLVMaHTgtpq9XSBLv66z1mCxA +z/OkUDiqgbBJ826N1Mo+NZg= +-----END PRIVATE KEY----- diff --git a/libwebapi/tests/data/webapi-test-server.crt b/libwebapi/tests/data/webapi-test-server.crt new file mode 100644 index 00000000..ce4d5269 --- /dev/null +++ b/libwebapi/tests/data/webapi-test-server.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTzCCAjegAwIBAgIUEAn1kxoe+1Y76iZuT4lSEg/CJVAwDQYJKoZIhvcNAQEL +BQAwHTEbMBkGA1UEAwwSU3lzaW5zcGVjdCBUZXN0IENBMB4XDTI2MDMyMTE4Mzc1 +M1oXDTI3MDMyMTE4Mzc1M1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoum06Im2QmRufsEOqB6k3z5dI0VSTdFH +rq4BZJZilU4DwNgiNBf2diNm3xmv0V/5gVypGK8eEuocpWjKtsTFIEd6A1L0bpqt +OmBum4bKl0Iu8wQE/0Wm+U4gM6MTdpLDe5ZIQGKoIEm1X1qrDrIpqJuM+9N1tQxi +N8w9S4NVVP8Bu8AZIUrAmw8x6rJHyR3w0tJo6NVu1pcyABuTlgsBrA4br0rgTyLx +u1HsFLgktI9mx6Ldj05fmaB+yMovKKWQYtd7bE92HJXWtnHRooqtQHFw1XbH8KTo +gwbwu7+T7AiHIw4lbiyUV7KRmhUED971EPCmX2mU2J2D8n3iGSLFCwIDAQABo4GP +MIGMMAkGA1UdEwQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUF +BwMBMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAdBgNVHQ4EFgQUVzFADb+l +BSIgmxRQGSc1kzkeey4wHwYDVR0jBBgwFoAUVRO26TCVbDvH9xyONLuTjYo6UYUw +DQYJKoZIhvcNAQELBQADggEBAAYkWtC0s/3Tasn04ovf6YhnGfnaocwvNKITZdyA +nGpimlozh0rEq/G/GgdEUqR0qIzFpfoNGqeJo6fG2a6g7k0SfMwLc6vFXGYWZeeL +4Pekvj0rcnSwuihUnsOi/3qrbV1x5DPQbui0OdkxRC7nmFOakwz4F7buFHa4tClp +GETr1n6HfFna/yr1XfuIsRWQeNP/Fwb4hJ1QWUX7a2GKNSLek2X0mt0YM2OPDQPC +RK11hLPtd9qcJWB9ToCoaSwhWqYtSdPAhgh3c2Md3IBCPiEBFh/7Ch9EjfWLHdb5 +Yehnf5SnxOsGEIhkq9xUvk9/IqXi51HfNd+NZsvNhH8YpeY= +-----END CERTIFICATE----- diff --git a/libwebapi/tests/data/webapi-test-server.key b/libwebapi/tests/data/webapi-test-server.key new file mode 100644 index 00000000..e0f5a6e6 --- /dev/null +++ b/libwebapi/tests/data/webapi-test-server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCi6bToibZCZG5+ +wQ6oHqTfPl0jRVJN0UeurgFklmKVTgPA2CI0F/Z2I2bfGa/RX/mBXKkYrx4S6hyl +aMq2xMUgR3oDUvRumq06YG6bhsqXQi7zBAT/Rab5TiAzoxN2ksN7lkhAYqggSbVf +WqsOsimom4z703W1DGI3zD1Lg1VU/wG7wBkhSsCbDzHqskfJHfDS0mjo1W7WlzIA +G5OWCwGsDhuvSuBPIvG7UewUuCS0j2bHot2PTl+ZoH7Iyi8opZBi13tsT3Yclda2 +cdGiiq1AcXDVdsfwpOiDBvC7v5PsCIcjDiVuLJRXspGaFQQP3vUQ8KZfaZTYnYPy +feIZIsULAgMBAAECggEAFLhz5YWqdExQz3dflVt8bdaBQxysiLA/FUUVcUU9Wa8Y +BB2ZUBXiJ4l+Ko8aUR+LXPw7l6OiSBaVuSYYbmGdjur4ZlbVNwIeWUftmYNt3gox +bYBL4GnsAFaC+v5ZWeH10hC9tM63go/NbUjba92WNdc++cKd/H6MOXuVKjcUkeA/ +DmvkRYsXvid6TE/ErrBrJpIVpVkaTf5hwNjuko8JYzlK8jsjHd76XhVDwVKB2ZMn +xSrL4BM3SXQvSXbjPD0xvvlpDTVuKzdgwTGDutP0GTWoGIoyrRUC5p1Vxg8G7eSj +UmnWyu3hhjPxm0tWvXArR3tbr37xUehIJwwioMySwQKBgQDie7LyMjnYyRKxmaCK +XGYEQ+qeC8O7w34UbfE2cN31QpYm8Z92woz0wcqXwGfAnh7ti/PiDHPTmz3LqHQA +ZDgUGwuBH/nPKGL+G13mNjbqcsgIch+0B45GaraprsEzVYz+oLRniVZEdsbKtIFL +jXQttaSGpImQMVU/RtTxe4V+SwKBgQC4JRKNLRfWce1/+E89req98/yftxn4wdf/ +mraNIg4rUM0hbRgTen5dAmKOhqSZGBUQY0TuvShcJ45NqiLXZzArXU5FzM2xny+y +813YhlWq183iXsFJ5MSCgPkPEOZz+DTEIo4Jup4/qPrkolHEAKlg5Y8Cc1fFHWm2 +N9YI/NicQQKBgHS1oeVFFKIuG8ABlsU2ECwqg4CmN1tOxm3oqeCQEREOGyo+YRpl +7xVBuBCzScPst6tZ73eRSy7EVPfZ+s0o1+0kcq07uROTkE+58o1raqkuNP6FMOko +65xF6ZNPRqgZcerVDaI9F4N4YcCbe/VfE3tqmzn3GByCD5fn/Fvkd0o5AoGBAJEJ +Ef2DwLy0at1aE/9+ld8a5qRdMOWOt7OohZPPeN2A/LARHt9ooVJcaIfdYJL8Nsr7 +hPWMotdCiIB/OoXxziy5hsbPMktuF8GYkRfTZnHzG0PqYc7zkhs/veqx4vEAU38P +wFPFWpLFYybk+gWoh7+7ztGdS0oDiplsjPXzQCCBAoGBAKO4sML3XtzdOkAhR7Z1 +uNmb+8jdkZWpM5RjuhM39luxS9qBpQ+4Nl+LYbxHp7iAXNbt/tkNgr/pq6xiEGzu +QjMxvFo1i/O9FSyP4w61WXV+SNfyog74quL0v5h7sItneDy+m865TkSgrwc4KfRn +uQ89QjhHGMBmy2xzMC0pIP3Y +-----END PRIVATE KEY----- From fd3dac2e7ffdf0f1ec7d50a0a2cb54c307a62d66 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 19:58:37 +0100 Subject: [PATCH 37/43] Add tests to freeze the update --- .../src/transport/secure_channel_ut.rs | 19 +++++++++++++++++++ libwebapi/src/lib_ut.rs | 17 +++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/libsysinspect/src/transport/secure_channel_ut.rs b/libsysinspect/src/transport/secure_channel_ut.rs index 49b382e8..c3613fa2 100644 --- a/libsysinspect/src/transport/secure_channel_ut.rs +++ b/libsysinspect/src/transport/secure_channel_ut.rs @@ -203,3 +203,22 @@ fn secure_channel_rejects_tampered_nonce() { assert!(master.open::(&serde_json::to_vec(&parsed).unwrap()).is_err()); } + +#[test] +fn secure_channel_uses_distinct_first_frame_nonces_per_direction() { + let (mut master, mut minion) = channels(); + let minion_frame = minion.seal(&serde_json::json!({"from":"minion"})).unwrap(); + let master_frame = master.seal(&serde_json::json!({"from":"master"})).unwrap(); + + let minion_frame = serde_json::from_slice::(&minion_frame).unwrap(); + let master_frame = serde_json::from_slice::(&master_frame).unwrap(); + + match (minion_frame, master_frame) { + (SecureFrame::Data(minion_data), SecureFrame::Data(master_data)) => { + assert_eq!(minion_data.counter, 1); + assert_eq!(master_data.counter, 1); + assert_ne!(minion_data.nonce, master_data.nonce); + } + _ => panic!("expected data frames"), + } +} diff --git a/libwebapi/src/lib_ut.rs b/libwebapi/src/lib_ut.rs index 300dee14..039f3186 100644 --- a/libwebapi/src/lib_ut.rs +++ b/libwebapi/src/lib_ut.rs @@ -59,6 +59,23 @@ fn load_tls_server_config_accepts_valid_certificate_pair() { assert!(load_tls_server_config(&cfg).is_ok()); } +#[test] +fn load_tls_server_config_accepts_valid_ca_bundle_for_client_auth() { + let root = tempfile::tempdir().unwrap(); + let (cert, key) = write_tls_fixture(root.path()); + let cfg = write_cfg( + root.path(), + &format!( + " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n api.tls.ca-file: {}\n", + cert.display(), + key.display(), + cert.display() + ), + ); + + assert!(load_tls_server_config(&cfg).is_ok()); +} + #[test] fn load_tls_server_config_rejects_missing_private_key() { let root = tempfile::tempdir().unwrap(); From cd67d9a2163a99fb33c795706c1c4c8f6337b1bd Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 21:54:22 +0100 Subject: [PATCH 38/43] Add architecture diagram --- docs/arch/current_system.drawio | 98 +++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/arch/current_system.drawio diff --git a/docs/arch/current_system.drawio b/docs/arch/current_system.drawio new file mode 100644 index 00000000..c50f58c7 --- /dev/null +++ b/docs/arch/current_system.drawio @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0ba597e84de81d4c99b967aaa0b8610443b42c35 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 21:54:47 +0100 Subject: [PATCH 39/43] Update docs --- docs/genusage/operator_security.rst | 7 ++++++- docs/genusage/security_model.rst | 4 ++-- docs/global_config.rst | 9 ++++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/genusage/operator_security.rst b/docs/genusage/operator_security.rst index 900b0d34..469e2f7d 100644 --- a/docs/genusage/operator_security.rst +++ b/docs/genusage/operator_security.rst @@ -154,11 +154,16 @@ Optional configuration: config: master: api.tls.ca-file: trust/ca.pem - api.tls.allow-insecure: false + api.tls.allow-insecure: true If ``api.tls.ca-file`` is set, the Web API requires client certificates signed by that CA bundle. +If the configured Web API certificate is self-signed, set +``api.tls.allow-insecure: true`` only when you intentionally want to allow +that certificate posture. Sysinspect will log a warning when it starts in that +mode. + Behavior: - if ``api.enabled`` is ``true`` but TLS is not configured correctly, the Web diff --git a/docs/genusage/security_model.rst b/docs/genusage/security_model.rst index 4278a689..15538f0e 100644 --- a/docs/genusage/security_model.rst +++ b/docs/genusage/security_model.rst @@ -80,8 +80,8 @@ Out of scope: - compromise of the master or minion host itself - theft of private keys from a compromised host - manual trust mistakes during initial fingerprint verification -- operator misuse of ``allow-insecure`` or other weak client-side trust - settings +- deliberate operator choice to allow self-signed Web API TLS +- weak client-side trust settings outside Sysinspect server configuration - generic browser, PAM, LDAP, or operating-system hardening outside Sysinspect itself diff --git a/docs/global_config.rst b/docs/global_config.rst index 694df79b..a372638e 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -398,7 +398,14 @@ Below are directives for the configuration of the File Server service: Type: **boolean** - Allow explicitly using insecure client trust handling for the Web API TLS setup. + Allow the embedded Web API to start with a self-signed or otherwise + non-public TLS certificate. + + When this option is ``false``, Sysinspect rejects a self-signed Web API + certificate during startup. + + When this option is ``true``, Sysinspect allows that setup and logs a + warning so operators know clients must explicitly trust the certificate. Default is ``false``. From 031942cb654a4567c2d121ffad6cac66176f5c84 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 21:55:15 +0100 Subject: [PATCH 40/43] Update deps, add docstrings --- Cargo.lock | 85 +++++++++++++++++++++++++++++---- libsysinspect/src/cfg/mmconf.rs | 4 +- libwebapi/Cargo.toml | 1 + 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 35e2716b..a6879bfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,13 +446,29 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "asn1-rs" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" dependencies = [ - "asn1-rs-derive", + "asn1-rs-derive 0.6.0", "asn1-rs-impl", "displaydoc", "nom", @@ -462,6 +478,18 @@ dependencies = [ "time", ] +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "asn1-rs-derive" version = "0.6.0" @@ -1824,13 +1852,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der-parser" version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", "displaydoc", "nom", "num-bigint", @@ -4046,6 +4088,7 @@ dependencies = [ "utoipa", "utoipa-swagger-ui", "uuid", + "x509-parser 0.16.0", ] [[package]] @@ -4719,13 +4762,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + [[package]] name = "oid-registry" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", ] [[package]] @@ -6899,7 +6951,7 @@ dependencies = [ "num-complex", "num-traits", "num_enum", - "oid-registry", + "oid-registry 0.8.1", "page_size", "parking_lot 0.12.5", "paste", @@ -6941,7 +6993,7 @@ dependencies = [ "widestring", "windows-sys 0.61.2", "x509-cert", - "x509-parser", + "x509-parser 0.18.1", "xml", ] @@ -10298,18 +10350,35 @@ dependencies = [ "tls_codec", ] +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry 0.7.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "x509-parser" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", "data-encoding", - "der-parser", + "der-parser 10.0.0", "lazy_static", "nom", - "oid-registry", + "oid-registry 0.8.1", "rusticata-macros", "thiserror 2.0.18", "time", diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index 53ab2212..c41f151f 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -841,7 +841,7 @@ pub struct MasterConfig { #[serde(rename = "api.tls.ca-file")] api_tls_ca_file: Option, - /// Allow explicitly using insecure client trust handling for the Web API TLS setup. + /// Allow self-signed or otherwise non-public Web API TLS certificates. #[serde(rename = "api.tls.allow-insecure")] api_tls_allow_insecure: Option, @@ -1036,7 +1036,7 @@ impl MasterConfig { self.api_tls_ca_file.as_deref().map(|path| self.resolve_rooted_path(path)) } - /// Return whether the Web API may explicitly use insecure client trust handling. + /// Return whether self-signed or otherwise non-public Web API TLS certificates are allowed. pub fn api_tls_allow_insecure(&self) -> bool { self.api_tls_allow_insecure.unwrap_or(false) } diff --git a/libwebapi/Cargo.toml b/libwebapi/Cargo.toml index 9d9e1c76..66c7f5c9 100644 --- a/libwebapi/Cargo.toml +++ b/libwebapi/Cargo.toml @@ -32,3 +32,4 @@ futures-util = "0.3.32" hostname = "0.4.1" rustls = "0.23.36" rustls-pemfile = "2.2.0" +x509-parser = "0.16.0" From 76b55c826f03fe02ca73247a21103a0c450cef74 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 21:55:27 +0100 Subject: [PATCH 41/43] Add UT for self signed certs --- libwebapi/src/lib_ut.rs | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/libwebapi/src/lib_ut.rs b/libwebapi/src/lib_ut.rs index 039f3186..71bf561e 100644 --- a/libwebapi/src/lib_ut.rs +++ b/libwebapi/src/lib_ut.rs @@ -1,4 +1,4 @@ -use super::{advertised_doc_url, load_tls_server_config, tls_paths_summary, tls_setup_err_message}; +use super::{advertised_doc_url, load_tls_server_config, tls_paths_summary, tls_self_signed_warning_message, tls_setup_err_message}; use libsysinspect::cfg::mmconf::MasterConfig; use std::{fs, path::Path, path::PathBuf}; @@ -50,7 +50,7 @@ fn load_tls_server_config_accepts_valid_certificate_pair() { let cfg = write_cfg( root.path(), &format!( - " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n", + " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n api.tls.allow-insecure: true\n", cert.display(), key.display() ), @@ -66,7 +66,7 @@ fn load_tls_server_config_accepts_valid_ca_bundle_for_client_auth() { let cfg = write_cfg( root.path(), &format!( - " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n api.tls.ca-file: {}\n", + " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n api.tls.ca-file: {}\n api.tls.allow-insecure: true\n", cert.display(), key.display(), cert.display() @@ -76,6 +76,24 @@ fn load_tls_server_config_accepts_valid_ca_bundle_for_client_auth() { assert!(load_tls_server_config(&cfg).is_ok()); } +#[test] +fn load_tls_server_config_rejects_self_signed_certificate_without_allow_insecure() { + let root = tempfile::tempdir().unwrap(); + let (cert, key) = write_tls_fixture(root.path()); + let cfg = write_cfg( + root.path(), + &format!( + " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n", + cert.display(), + key.display() + ), + ); + + let err = load_tls_server_config(&cfg).unwrap_err().to_string(); + assert!(err.contains("self-signed")); + assert!(err.contains("api.tls.allow-insecure")); +} + #[test] fn load_tls_server_config_rejects_missing_private_key() { let root = tempfile::tempdir().unwrap(); @@ -98,7 +116,7 @@ fn load_tls_server_config_rejects_invalid_ca_bundle() { let cfg = write_cfg( root.path(), &format!( - " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n api.tls.ca-file: {}\n", + " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n api.tls.ca-file: {}\n api.tls.allow-insecure: true\n", cert.display(), key.display(), ca.display() @@ -127,3 +145,11 @@ fn tls_paths_summary_reports_configured_locations() { assert!(summary.contains(&cert.display().to_string())); assert!(summary.contains(&key.display().to_string())); } + +#[test] +fn tls_self_signed_warning_is_operator_facing() { + let msg = tls_self_signed_warning_message(); + assert!(msg.contains("self-signed")); + assert!(msg.contains("api.tls.allow-insecure")); + assert!(msg.contains("explicitly trust")); +} From 731edaaf5c57688cc815542ce306a5b1dab37f47 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 21:55:50 +0100 Subject: [PATCH 42/43] Drop used secret from the memory. Just in case. --- libsysinspect/src/transport/secure_bootstrap.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libsysinspect/src/transport/secure_bootstrap.rs b/libsysinspect/src/transport/secure_bootstrap.rs index d68e7a54..f9ed8b15 100644 --- a/libsysinspect/src/transport/secure_bootstrap.rs +++ b/libsysinspect/src/transport/secure_bootstrap.rs @@ -220,7 +220,7 @@ impl SecureBootstrapSession { session_id: Some(params.session_id.clone()), offered_versions: vec![binding.protocol_version], local_ephemeral_public: params.master_ephemeral_public, - local_ephemeral_secret: Some(params.master_ephemeral_secret), + local_ephemeral_secret: None, }, SecureFrame::BootstrapAck(SecureBootstrapAck { binding, From f5e1861d9dfab4f5aa2b46827aa0f8708072d305 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 21 Mar 2026 21:56:16 +0100 Subject: [PATCH 43/43] Fix self signed routine --- libwebapi/src/lib.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/libwebapi/src/lib.rs b/libwebapi/src/lib.rs index bdfd1055..efe8a6e1 100644 --- a/libwebapi/src/lib.rs +++ b/libwebapi/src/lib.rs @@ -9,6 +9,7 @@ use rustls::RootCertStore; use rustls::server::WebPkiClientVerifier; use std::{fs::File, io::BufReader, sync::Arc, thread}; use tokio::sync::Mutex; +use x509_parser::prelude::parse_x509_certificate; pub mod api; #[cfg(test)] @@ -54,6 +55,22 @@ pub(crate) fn tls_setup_err_message() -> String { ) } +/// Returns a user-friendly warning message about using a self-signed TLS certificate for WebAPI, pointing to the relevant configuration option. +pub(crate) fn tls_self_signed_warning_message() -> String { + format!( + "Embedded Web API is using a {} TLS certificate because {} is set to {}. Clients must explicitly trust this certificate.", + "self-signed".bright_red(), + "api.tls.allow-insecure".bright_yellow(), + "true".bright_yellow() + ) +} + +fn cert_appears_self_signed(cert_der: &[u8]) -> Result { + let (_, cert) = parse_x509_certificate(cert_der) + .map_err(|err| SysinspectError::ConfigError(format!("Unable to parse Web API TLS certificate for trust checks: {err}")))?; + Ok(cert.tbs_certificate.subject == cert.tbs_certificate.issuer) +} + /// Loads the TLS server configuration for the Web API from the provided MasterConfig. /// This includes reading the certificate and private key files, and optionally /// the CA file for client certificate authentication. @@ -79,6 +96,12 @@ fn load_tls_server_config(cfg: &MasterConfig) -> Result log::info!("Starting embedded Web API inside sysmaster at {} over {}", listen_addr.bright_yellow(), "HTTPS/TLS"); log::info!("Embedded Web API enabled. Swagger UI available at {}", advertised_doc_url(&bind_addr, bind_port, true).bright_yellow()); if ccfg.api_tls_allow_insecure() { - log::warn!("Web API TLS allow-insecure mode is enabled for clients."); + log::warn!("{}", tls_self_signed_warning_message()); } - actix_web::rt::System::new().block_on(async move { let server = HttpServer::new(move || { let mut scope = web::scope("");