diff --git a/.env.dev b/.env.dev index cdd6af510..fd7aa3ba4 100644 --- a/.env.dev +++ b/.env.dev @@ -1,3 +1,7 @@ +# Non-secret development overlays used while generating the Axum config blob. +# Sourcing this file alone does not configure the Axum server: also export the +# blob and referenced secret-store values as shown in docs/guide/getting-started.md. + # [publisher] TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=http://localhost:9090 diff --git a/.env.example b/.env.example index c2ac88e3a..87a3502d2 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,12 @@ -# Trusted Server Environment Variables -# Copy this file to .env.dev, .env.staging, or .env.production and fill in values -# See docs/guide/configuration.md for details +# Trusted Server development environment variables +# Copy this file to .env.dev, .env.staging, or .env.production and fill in +# non-secret values. App-config secrets are key names in the pushed blob and +# their values belong in the platform secret store; see the configuration guide. +# For Axum runtime loading, export the config blob as: +# TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG= +# and export one secret per key name as: +# TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_= +# The commented examples below are CLI overlays for ordinary fields only. # ============================================================================= # Publisher Settings @@ -8,14 +14,12 @@ TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com TRUSTED_SERVER__PUBLISHER__COOKIE_DOMAIN=.publisher.com TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com -TRUSTED_SERVER__PUBLISHER__PROXY_SECRET= # ============================================================================= # Synthetic ID Settings # ============================================================================= TRUSTED_SERVER__SYNTHETIC__COUNTER_STORE=counter_store TRUSTED_SERVER__SYNTHETIC__OPID_STORE=opid_store -TRUSTED_SERVER__SYNTHETIC__SECRET_KEY= # Template variables: client_ip, user_agent, first_party_id, auth_user_id, publisher_domain, accept_language TRUSTED_SERVER__SYNTHETIC__TEMPLATE={{ client_ip }}:{{ user_agent }}:{{ first_party_id }} diff --git a/Cargo.lock b/Cargo.lock index cb8f40c68..b65cacdbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1398,7 +1398,7 @@ dependencies = [ [[package]] name = "edgezero-adapter" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "toml", ] @@ -1406,7 +1406,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-axum" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "anyhow", "async-trait", @@ -1434,7 +1434,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "anyhow", "async-trait", @@ -1449,7 +1449,7 @@ dependencies = [ "log", "serde_json", "tempfile", - "toml_edit", + "toml_edit 0.25.12+spec-1.1.0", "walkdir", "worker", ] @@ -1457,7 +1457,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "anyhow", "async-stream", @@ -1479,14 +1479,14 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.18", - "toml_edit", + "toml_edit 0.25.12+spec-1.1.0", "walkdir", ] [[package]] name = "edgezero-adapter-spin" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "anyhow", "async-trait", @@ -1506,14 +1506,14 @@ dependencies = [ "subtle", "thiserror 2.0.18", "toml", - "toml_edit", + "toml_edit 0.25.12+spec-1.1.0", "walkdir", ] [[package]] name = "edgezero-cli" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "chrono", "clap", @@ -1538,7 +1538,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "anyhow", "async-compression", @@ -1569,7 +1569,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "log", "proc-macro2", @@ -5074,6 +5074,19 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -5340,7 +5353,7 @@ dependencies = [ "tokio", "tokio-rustls", "toml", - "toml_edit", + "toml_edit 0.23.10+spec-1.0.0", "trusted-server-core", "url", "webpki-roots", diff --git a/Cargo.toml b/Cargo.toml index 7ca87e687..16a2109e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,12 +54,12 @@ criterion = { version = "0.5", default-features = false, features = ["cargo_benc derive_more = { version = "2.0", features = ["display", "error"] } directories = "5" ed25519-dalek = { version = "2.2", features = ["rand_core"] } -edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false } -edgezero-cli = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4" } -edgezero-core = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } +edgezero-cli = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e" } +edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } env_logger = "0.11" error-stack = "0.6" fastly = "0.12" diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 1bed830ac..e7cf40ded 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -33,7 +33,7 @@ use trusted_server_core::settings_data::{ use trusted_server_core::platform::RuntimeServices; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; -use crate::platform::{AxumPlatformConfigStore, build_runtime_services}; +use crate::platform::{AxumPlatformConfigStore, AxumPlatformSecretStore, build_runtime_services}; // --------------------------------------------------------------------------- // AppState @@ -55,8 +55,13 @@ pub struct AppState { fn build_state() -> Result, Report> { let store_name = default_config_store_name(); let config_key = default_config_key(); - let settings = - get_settings_from_config_store(&AxumPlatformConfigStore, &store_name, &config_key)?; + let settings = get_settings_from_config_store( + &AxumPlatformConfigStore, + &AxumPlatformSecretStore, + &store_name, + &config_key, + &trusted_server_core::settings_data::default_secret_store_name(), + )?; build_state_with_settings(settings) } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 644676fc5..b71afe830 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -11,11 +11,13 @@ use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; #[cfg(target_arch = "wasm32")] -use trusted_server_core::config_payload::settings_from_config_blob; +use trusted_server_core::config_payload::{DEFAULT_SECRET_STORE_ID, settings_from_config_blob}; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; use trusted_server_core::platform::RuntimeServices; +#[cfg(target_arch = "wasm32")] +use trusted_server_core::platform::StoreName; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -38,11 +40,23 @@ use crate::platform::build_runtime_services; // --------------------------------------------------------------------------- #[cfg(target_arch = "wasm32")] -static CLOUDFLARE_CONFIG_JSON: std::sync::OnceLock = std::sync::OnceLock::new(); +thread_local! { + static CLOUDFLARE_CONFIG_JSON: std::cell::OnceCell = const { std::cell::OnceCell::new() }; + static CLOUDFLARE_ENV: std::cell::OnceCell = const { std::cell::OnceCell::new() }; +} #[cfg(target_arch = "wasm32")] pub fn set_cloudflare_config_json(value: String) { - let _ = CLOUDFLARE_CONFIG_JSON.set(value); + CLOUDFLARE_CONFIG_JSON.with(|slot| { + let _ = slot.set(value); + }); +} + +#[cfg(target_arch = "wasm32")] +pub fn set_cloudflare_env(env: worker::Env) { + CLOUDFLARE_ENV.with(|slot| { + let _ = slot.set(env); + }); } /// Application state built once at startup and shared across all requests. @@ -70,18 +84,22 @@ fn load_startup_settings() -> Result> { #[cfg(not(target_arch = "wasm32"))] fn load_startup_settings() -> Result> { - Settings::from_toml(include_str!("../../../trusted-server.example.toml")) + Err(Report::new(TrustedServerError::Configuration { + message: "Cloudflare startup settings require a Worker config binding".to_string(), + }) + .attach("use TrustedServerApp::routes_with_settings for host tests")) } #[cfg(target_arch = "wasm32")] fn settings_from_cloudflare_config_json() -> Result> { - let raw_config = CLOUDFLARE_CONFIG_JSON.get().ok_or_else(|| { + let raw_config = CLOUDFLARE_CONFIG_JSON.with(|slot| slot.get().cloned()); + let raw_config = raw_config.ok_or_else(|| { Report::new(TrustedServerError::Configuration { message: "Cloudflare TRUSTED_SERVER_CONFIG is required".to_string(), }) .attach("set TRUSTED_SERVER_CONFIG to JSON containing the app_config blob envelope") })?; - let value: serde_json::Value = serde_json::from_str(raw_config).map_err(|error| { + let value: serde_json::Value = serde_json::from_str(&raw_config).map_err(|error| { Report::new(TrustedServerError::Configuration { message: "invalid Cloudflare TRUSTED_SERVER_CONFIG JSON".to_string(), }) @@ -95,7 +113,16 @@ fn settings_from_cloudflare_config_json() -> Result Result { if let Ok(config) = env.var("TRUSTED_SERVER_CONFIG") { app::set_cloudflare_config_json(config.to_string()); } + app::set_cloudflare_env(env.clone()); match edgezero_adapter_cloudflare::run_app::(req, env, ctx).await { Ok(resp) => Ok(resp), diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index fff0bfed1..d9ef8583a 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -547,8 +547,8 @@ impl PlatformHttpClient for CloudflareHttpClient { /// Bridges [`worker::Env`] secrets to [`PlatformSecretStore`] by calling /// `env.secret(key)` synchronously. Writes and deletes return errors. #[cfg(target_arch = "wasm32")] -struct CloudflareSecretStoreAdapter { - env: worker::Env, +pub(crate) struct CloudflareSecretStoreAdapter { + pub(crate) env: worker::Env, } #[cfg(target_arch = "wasm32")] diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml index e6891eb79..9992db712 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml @@ -14,3 +14,12 @@ id = "ci-local-kv" # Placeholder replaced by the integration test harness with a JSON object that # contains the runtime Trusted Server app-config blob envelope. TRUSTED_SERVER_CONFIG = "{}" + +# Fictitious integration-only secret values. `worker::Env::secret` reads these +# string bindings in local Wrangler runs; production values are provisioned with +# `wrangler secret put` instead of being committed to a manifest. +integration_admin_password = "integration-admin-password-32-bytes-ok" +integration_proxy_secret = "integration-test-proxy-secret-32-bytes-ok" +integration_ec_passphrase = "integration-test-ec-secret-padded-32" +integration_partner_token_alpha = "integration-test-token-alpha-32-bytes-ok" +integration_partner_token_bravo = "integration-test-token-bravo-32-bytes-ok" diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml index 7c91173fc..48eb2db8d 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -26,3 +26,7 @@ id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" # invalid placeholder with JSON containing an `app_config` blob envelope before # deploying or running `wrangler dev` against real traffic. TRUSTED_SERVER_CONFIG = '{"app_config":""}' + +# App-config secret values are provisioned as Worker secrets with +# `wrangler secret put `. The pushed blob contains only those key +# names; never add secret values to this file. diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index d6090c983..96c06637b 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -166,7 +166,13 @@ pub(crate) fn build_state() -> Result, Report> pub(crate) fn load_settings_from_config_store() -> Result> { let store_name = default_config_store_name(); let config_key = default_config_key(); - get_settings_from_config_store(&FastlyPlatformConfigStore, &store_name, &config_key) + get_settings_from_config_store( + &FastlyPlatformConfigStore, + &FastlyPlatformSecretStore, + &store_name, + &config_key, + &trusted_server_core::settings_data::default_secret_store_name(), + ) } pub(crate) fn build_state_from_settings( diff --git a/crates/trusted-server-adapter-spin/spin.toml b/crates/trusted-server-adapter-spin/spin.toml index 9bc3634d8..1e746ab3b 100644 --- a/crates/trusted-server-adapter-spin/spin.toml +++ b/crates/trusted-server-adapter-spin/spin.toml @@ -25,6 +25,13 @@ version = "0.1.0" [variables] v_current_x2dkid = { default = "" } v_active_x2dkids = { default = "" } +# Trusted Server app-config secret references. Replace the empty defaults with +# values supplied by the deployment's secret provider; never commit values here. +v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret = { default = "", secret = true } +v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase = { default = "", secret = true } +v_trusted_x5fserver_x5fsecrets_v_partner_x5fapi_x5ftoken = { default = "", secret = true } +v_trusted_x5fserver_x5fsecrets_v_partner_x5fts_x5fpull_x5ftoken = { default = "", secret = true } +v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword = { default = "", secret = true } [[trigger.http]] route = "/..." @@ -43,6 +50,11 @@ key_value_stores = ["default"] [component.trusted-server.variables] v_current_x2dkid = "{{ v_current_x2dkid }}" v_active_x2dkids = "{{ v_active_x2dkids }}" +v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret = "{{ v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret }}" +v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase = "{{ v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase }}" +v_trusted_x5fserver_x5fsecrets_v_partner_x5fapi_x5ftoken = "{{ v_trusted_x5fserver_x5fsecrets_v_partner_x5fapi_x5ftoken }}" +v_trusted_x5fserver_x5fsecrets_v_partner_x5fts_x5fpull_x5ftoken = "{{ v_trusted_x5fserver_x5fsecrets_v_partner_x5fts_x5fpull_x5ftoken }}" +v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword = "{{ v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword }}" [component.trusted-server.build] command = "cargo build --target wasm32-wasip1 --release -p trusted-server-adapter-spin --features spin" diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 960bafc41..5fe70e0e4 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1,8 +1,12 @@ use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use edgezero_adapter_spin::config_store::SpinConfigStore; use edgezero_adapter_spin::context::SpinRequestContext; use edgezero_core::app::Hooks; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Method, Request, Response, StatusCode, header}; @@ -10,11 +14,15 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; use trusted_server_core::platform::RuntimeServices; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use trusted_server_core::platform::{PlatformConfigStore, StoreName}; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -28,14 +36,22 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use trusted_server_core::settings_data::{default_config_key, default_secret_store_name}; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware}; use crate::platform::build_runtime_services; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use crate::platform::{ConfigStoreHandleAdapter, SpinSecretStoreAdapter}; // --------------------------------------------------------------------------- // AppState // --------------------------------------------------------------------------- +/// Spin auto-provides this key-value store label without runtime configuration. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +const SPIN_DEFAULT_CONFIG_STORE: &str = "default"; + /// Application state built once at startup and shared across all requests. pub struct AppState { settings: Arc, @@ -50,10 +66,44 @@ pub struct AppState { /// Returns an error when settings, the auction orchestrator, or the integration /// registry fail to initialise. fn build_state() -> Result, Report> { - let settings = Settings::from_toml(include_str!("../../../trusted-server.example.toml"))?; + let settings = load_startup_settings()?; build_state_with_settings(settings) } +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +fn load_startup_settings() -> Result> { + let config_store_name = StoreName::from(SPIN_DEFAULT_CONFIG_STORE); + let config_key = default_config_key(); + let config_store = + futures::executor::block_on(SpinConfigStore::open(config_store_name.as_ref().to_owned())) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "failed to open Spin Trusted Server config store".to_string(), + }) + .attach(error.to_string()) + })?; + let config_handle = ConfigStoreHandle::new(Arc::new(config_store)); + let config_adapter = ConfigStoreHandleAdapter(config_handle); + let raw_envelope = config_adapter + .get(&config_store_name, &config_key) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "failed to read Spin Trusted Server app-config blob".to_string(), + }) + .attach(error.to_string()) + })?; + let secret_store = SpinSecretStoreAdapter; + settings_from_config_blob(&raw_envelope, &secret_store, &default_secret_store_name()) +} + +#[cfg(not(all(feature = "spin", target_arch = "wasm32")))] +fn load_startup_settings() -> Result> { + Err(Report::new(TrustedServerError::Configuration { + message: "Spin startup settings require the production config store".to_string(), + }) + .attach("use TrustedServerApp::routes_with_settings for host tests")) +} + /// Build the application state from explicit settings. /// /// # Errors diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 492f1a518..0f05ef17d 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -39,6 +39,7 @@ type HeaderPairs = Vec<(String, Vec)>; #[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] type BufferedResponseParts = (HeaderPairs, Vec); +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] const SPIN_VARIABLE_HEX: &[u8; 16] = b"0123456789abcdef"; // --------------------------------------------------------------------------- @@ -116,25 +117,22 @@ impl PlatformBackend for NoopBackend { /// Bridges edgezero's [`ConfigStoreHandle`] to [`PlatformConfigStore`]. /// -/// Reads delegate through the handle after mapping Trusted Server keys to Spin -/// variable names. Writes are unsupported on current Spin runtime config and -/// return typed errors. -struct ConfigStoreHandleAdapter(ConfigStoreHandle); +/// Spin config stores are KV-backed, so reads preserve the requested key +/// verbatim. Writes are unsupported on current Spin runtime config and return +/// typed errors. +pub(crate) struct ConfigStoreHandleAdapter(pub(crate) ConfigStoreHandle); impl PlatformConfigStore for ConfigStoreHandleAdapter { fn get(&self, _store_name: &StoreName, key: &str) -> Result> { - let variable_name = spin_variable_name(key, PlatformError::ConfigStore)?; - futures::executor::block_on(self.0.get(&variable_name)) - .map_err(|e| { - Report::new(PlatformError::ConfigStore) - .attach(format!( - "config store lookup failed for key `{key}` as Spin variable `{variable_name}`: {e}" - )) - })? - .ok_or_else(|| { + futures::executor::block_on(self.0.get(key)) + .map_err(|error| { Report::new(PlatformError::ConfigStore).attach(format!( - "key `{key}` not found as Spin variable `{variable_name}`" + "config store lookup failed for key `{key}`: {error}" )) + })? + .ok_or_else(|| { + Report::new(PlatformError::ConfigStore) + .attach(format!("key `{key}` not found in Spin config store")) }) } @@ -149,6 +147,7 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { } } +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] fn spin_variable_name( key: &str, error_context: PlatformError, @@ -187,6 +186,7 @@ fn spin_variable_name( Ok(out) } +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] fn push_spin_variable_escape(out: &mut String, byte: u8) { out.push('_'); out.push('x'); @@ -676,7 +676,7 @@ fn into_spin_method(method: &edgezero_core::http::Method) -> spin_sdk::http::Met /// with a real secret-provider source (e.g. Vault, Azure Key Vault) to avoid /// storing signing keys in plaintext on disk. #[cfg(all(feature = "spin", target_arch = "wasm32"))] -struct SpinSecretStoreAdapter; +pub(crate) struct SpinSecretStoreAdapter; #[cfg(all(feature = "spin", target_arch = "wasm32"))] impl PlatformSecretStore for SpinSecretStoreAdapter { @@ -794,6 +794,7 @@ mod tests { use super::*; use edgezero_core::body::Body; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; use edgezero_core::context::RequestContext; use edgezero_core::http::request_builder; use edgezero_core::params::PathParams; @@ -801,6 +802,15 @@ mod tests { use flate2::write::GzEncoder; use std::io::Write as _; + struct InMemoryConfigStore(std::collections::BTreeMap); + + #[async_trait::async_trait(?Send)] + impl ConfigStore for InMemoryConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.0.get(key).cloned()) + } + } + fn make_ctx_without_spin_context() -> RequestContext { let req = request_builder() .method("GET") @@ -894,6 +904,29 @@ mod tests { ); } + #[test] + fn config_store_handle_adapter_reads_verbatim_kv_key() { + let handle = ConfigStoreHandle::new(Arc::new(InMemoryConfigStore( + std::collections::BTreeMap::from([( + "trusted_server_config".to_owned(), + "blob-envelope".to_owned(), + )]), + ))); + let adapter = ConfigStoreHandleAdapter(handle); + + let value = adapter + .get( + &StoreName::from("trusted_server_config"), + "trusted_server_config", + ) + .expect("should read the verbatim config-store key"); + + assert_eq!( + value, "blob-envelope", + "should not translate a KV-backed config key into a Spin variable name" + ); + } + #[test] fn spin_variable_name_encodes_trusted_server_keys() { assert_eq!( diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index e74ef4150..19bd7d070 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -9,6 +9,7 @@ use std::borrow::Cow; use std::collections::HashSet; +use edgezero_core::app_config::{SecretField, SecretKind, SecretPathSegment}; use error_stack::Report; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use validator::{Validate, ValidationError, ValidationErrors}; @@ -25,6 +26,7 @@ use crate::integrations::{ use crate::settings::{IntegrationConfig, Settings}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; +const MIN_PROXY_SECRET_LENGTH: usize = 32; #[cfg(test)] const DEPLOY_VALIDATED_INTEGRATION_IDS: &[&str] = &[ "prebid", @@ -54,15 +56,20 @@ pub struct TrustedServerAppConfig { } impl TrustedServerAppConfig { - /// Creates a validated app-config wrapper from [`Settings`]. + /// Creates a push-valid app-config wrapper from [`Settings`]. /// /// # Errors /// - /// Returns [`TrustedServerError::Configuration`] when deploy validation + /// Returns [`TrustedServerError::Configuration`] when push-safe validation /// fails. pub fn new(settings: Settings) -> Result> { - validate_settings_for_deploy(&settings)?; - Ok(Self { settings }) + let app_config = Self { settings }; + edgezero_core::app_config::validate_excluding_secrets(&app_config).map_err(|errors| { + Report::new(TrustedServerError::Configuration { + message: format!("Configuration validation failed: {errors}"), + }) + })?; + Ok(app_config) } /// Consumes the wrapper and returns the inner [`Settings`]. @@ -92,41 +99,107 @@ impl<'de> Deserialize<'de> for TrustedServerAppConfig { where D: Deserializer<'de>, { - let settings = Settings::deserialize(deserializer)?; - let settings = Settings::finalize_deserialized(settings, "Configuration") - .map_err(serde::de::Error::custom)?; + let mut settings = Settings::deserialize(deserializer)?; + settings.normalize_deserialized(); Ok(Self { settings }) } } impl Validate for TrustedServerAppConfig { fn validate(&self) -> Result<(), ValidationErrors> { - validate_settings_for_deploy(&self.settings) - .map_err(|report| report_to_validation_errors(&report)) + let mut errors = self.settings.validate().err().unwrap_or_default(); + if let Err(report) = validate_settings_for_deploy(&self.settings) { + errors.add( + DEPLOY_VALIDATION_FIELD, + report_to_validation_error(&report, "trusted_server_deploy_validation"), + ); + } + if errors.errors().is_empty() { + Ok(()) + } else { + Err(errors) + } } } impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { - // Phase 1 intentionally preserves the existing inline-settings model: - // `ts config push` publishes the validated Trusted Server config as one - // app-config blob. Migrating app-level secrets to `EdgeZero` secret-store - // references needs nested/array extraction support and operator migration - // work tracked separately. - const SECRET_FIELDS: &'static [edgezero_core::app_config::SecretField] = &[]; + fn secret_fields() -> Vec { + let field = |path: Vec, optional| SecretField { + kind: SecretKind::KeyInDefault, + optional, + path, + }; + let object = |name: &'static str| SecretPathSegment::Field(Cow::Borrowed(name)); + + vec![ + field(vec![object("publisher"), object("proxy_secret")], false), + field(vec![object("ec"), object("passphrase")], false), + field( + vec![ + object("ec"), + object("partners"), + SecretPathSegment::ArrayEach, + object("api_token"), + ], + false, + ), + field( + vec![ + object("ec"), + object("partners"), + SecretPathSegment::ArrayEach, + object("ts_pull_token"), + ], + true, + ), + field( + vec![ + object("handlers"), + SecretPathSegment::ArrayEach, + object("password"), + ], + false, + ), + ] + } } -/// Runs Trusted Server deploy-time validation for pushed app config. +/// Runs Trusted Server push-time validation for app config. /// -/// This supplements [`Settings`] structural validation with checks that should -/// fail before an operator publishes a config blob: placeholder secrets, -/// enabled integration startup checks, auction provider references, and EC -/// partner registry construction. +/// Secret fields contain secret-store key names at this stage, so this function +/// deliberately excludes checks that require resolved values. The `EdgeZero` CLI +/// additionally calls [`edgezero_core::app_config::validate_excluding_secrets`] +/// to remove validators attached to those leaves. /// /// # Errors /// -/// Returns [`TrustedServerError`] when the config should not be deployed. +/// Returns [`TrustedServerError`] when non-secret configuration or a secret key +/// reference is invalid. pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report> { + validate_secret_key_references(settings)?; + + let mut structural_settings = settings.clone(); + structural_settings.prepare_runtime()?; + structural_settings.validate_admin_coverage()?; + + let enabled_auction_providers = validate_enabled_integrations(settings)?; + validate_auction_provider_names(settings, &enabled_auction_providers)?; + PartnerRegistry::validate_config_for_deploy(&settings.ec.partners)?; + Ok(()) +} + +/// Runs Trusted Server runtime validation after secret references are resolved. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] when resolved secrets or runtime-only +/// configuration checks are invalid. +pub fn validate_settings_for_runtime( + settings: &Settings, +) -> Result<(), Report> { settings.reject_placeholder_secrets()?; + validate_proxy_secret_strength(settings)?; + settings.validate_admin_handler_passwords()?; let enabled_auction_providers = validate_enabled_integrations(settings)?; validate_auction_provider_names(settings, &enabled_auction_providers)?; PartnerRegistry::from_config(&settings.ec.partners).map(|_| ())?; @@ -180,6 +253,59 @@ where .map(|config| config.is_some()) } +fn validate_secret_key_references(settings: &Settings) -> Result<(), Report> { + validate_secret_key_reference( + "publisher.proxy_secret", + settings.publisher.proxy_secret.expose(), + )?; + validate_secret_key_reference("ec.passphrase", settings.ec.passphrase.expose())?; + + for (index, partner) in settings.ec.partners.iter().enumerate() { + validate_secret_key_reference( + &format!("ec.partners[{index}].api_token"), + partner.api_token.expose(), + )?; + if let Some(token) = &partner.ts_pull_token { + validate_secret_key_reference( + &format!("ec.partners[{index}].ts_pull_token"), + token.expose(), + )?; + } + } + + for (index, handler) in settings.handlers.iter().enumerate() { + validate_secret_key_reference( + &format!("handlers[{index}].password"), + handler.password.expose(), + )?; + } + + Ok(()) +} + +fn validate_secret_key_reference( + path: &str, + key_name: &str, +) -> Result<(), Report> { + if key_name.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("secret key reference at `{path}` must not be empty"), + })); + } + Ok(()) +} + +fn validate_proxy_secret_strength(settings: &Settings) -> Result<(), Report> { + if settings.publisher.proxy_secret.expose().len() < MIN_PROXY_SECRET_LENGTH { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "publisher.proxy_secret must be at least {MIN_PROXY_SECRET_LENGTH} bytes after secret resolution" + ), + })); + } + Ok(()) +} + fn validate_auction_provider_names( settings: &Settings, enabled_auction_providers: &HashSet<&'static str>, @@ -206,19 +332,21 @@ fn validate_auction_provider_names( Ok(()) } -fn report_to_validation_errors(report: &Report) -> ValidationErrors { - let mut error = ValidationError::new("trusted_server_deploy_validation"); +fn report_to_validation_error( + report: &Report, + code: &'static str, +) -> ValidationError { + let mut error = ValidationError::new(code); error.message = Some(Cow::Owned(report.to_string())); - - let mut errors = ValidationErrors::new(); - errors.add(DEPLOY_VALIDATION_FIELD, error); - errors + error } #[cfg(test)] mod tests { use super::*; + use crate::redacted::Redacted; use crate::test_support::tests::crate_test_settings_str; + use edgezero_core::app_config::AppConfigMeta; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -233,7 +361,9 @@ mod tests { slot: Vec, } - fn serialized_creative_opportunities(gam_unit_path: Option<&str>) -> serde_json::Value { + fn app_config_with_creative_opportunities( + gam_unit_path: Option<&str>, + ) -> TrustedServerAppConfig { let mut toml = crate_test_settings_str(); toml.push_str( r#" @@ -251,9 +381,15 @@ formats = [{ width = 300, height = 250 }] toml.push_str(&format!("gam_unit_path = {gam_unit_path:?}\n")); } - let app_config: TrustedServerAppConfig = + let mut app_config: TrustedServerAppConfig = toml::from_str(&toml).expect("should deserialize app config wrapper"); - serde_json::to_value(app_config) + app_config.settings.proxy.allowed_domains = + vec!["*.example".to_owned(), "*.example.com".to_owned()]; + app_config + } + + fn serialized_creative_opportunities(gam_unit_path: Option<&str>) -> serde_json::Value { + serde_json::to_value(app_config_with_creative_opportunities(gam_unit_path)) .expect("should serialize app config wrapper") .get("creative_opportunities") .cloned() @@ -297,18 +433,65 @@ formats = [{ width = 300, height = 250 }] } #[test] - fn dynamic_gam_unit_templates_are_rejected_by_legacy_schema() { - for gam_unit_path in ["/{network_id}/example", "/example/{slot_id}"] { - let creative_opportunities = serialized_creative_opportunities(Some(gam_unit_path)); - let err = - serde_json::from_value::(creative_opportunities) - .expect_err("should reject dynamic GAM unit template"); + fn push_validation_accepts_secret_key_names() { + let mut settings = valid_settings(); + settings.publisher.proxy_secret = Redacted::new("publisher_proxy".to_owned()); + settings.ec.passphrase = Redacted::new("ec_key".to_owned()); + settings.handlers[0].password = Redacted::new("handler_password".to_owned()); + settings.handlers[1].password = Redacted::new("admin_password".to_owned()); + let app_config = TrustedServerAppConfig::new(settings) + .expect("should validate key names without values"); + + let serialized = + serde_json::to_string(&app_config).expect("should serialize key-name-only app config"); + assert!(serialized.contains("publisher_proxy")); + assert!(!serialized.contains("unit-test-proxy-secret")); + } - assert!( - err.to_string().contains("section_segment"), - "legacy error should name section_segment: {err}" - ); - } + #[test] + fn secret_metadata_lists_all_secret_paths_and_optionality() { + let fields = TrustedServerAppConfig::secret_fields(); + let paths = fields + .iter() + .map(|field| (field.dotted_path(), field.optional)) + .collect::>(); + + assert_eq!( + paths, + vec![ + ("publisher.proxy_secret".to_owned(), false), + ("ec.passphrase".to_owned(), false), + ("ec.partners[*].api_token".to_owned(), false), + ("ec.partners[*].ts_pull_token".to_owned(), true), + ("handlers[*].password".to_owned(), false), + ], + "should expose the native EdgeZero secret metadata contract" + ); + assert!( + fields.iter().all(|field| matches!( + field.kind, + edgezero_core::app_config::SecretKind::KeyInDefault + )), + "all Trusted Server app secrets should use the default secret store" + ); + } + + #[test] + fn app_config_deserialization_does_not_finalize_runtime_templates() { + let creative_opportunities = + serialized_creative_opportunities(Some("/{network_id}/example")); + let slot = creative_opportunities["slot"][0] + .as_object() + .expect("should serialize creative opportunity slot"); + + assert!( + slot.contains_key("gam_unit_path"), + "push deserialization should preserve the operator config field" + ); + assert!( + !slot.contains_key("section_segment"), + "push deserialization should not add runtime-only compiled fields" + ); } #[test] @@ -328,7 +511,53 @@ formats = [{ width = 300, height = 250 }] } #[test] - fn deploy_validation_rejects_placeholders() { + fn app_config_new_rejects_empty_secret_key_reference() { + let mut settings = valid_settings(); + settings.publisher.proxy_secret = Redacted::new(String::new()); + + let err = TrustedServerAppConfig::new(settings) + .expect_err("should reject an empty secret key reference"); + + assert!( + err.to_string().contains("publisher.proxy_secret"), + "error should identify the empty secret reference: {err:?}" + ); + } + + #[test] + fn app_config_new_rejects_invalid_non_secret_settings() { + let mut settings = valid_settings(); + settings.publisher.domain = "invalid/domain".to_owned(); + + let err = TrustedServerAppConfig::new(settings) + .expect_err("should reject invalid publisher domain before creating an app config"); + + assert!( + err.to_string().contains("invalid_publisher_domain"), + "error should identify the structural validation failure: {err:?}" + ); + } + + #[test] + fn runtime_validation_rejects_short_proxy_secret() { + let mut settings = valid_settings(); + settings.publisher.proxy_secret = Redacted::new("short".to_owned()); + + let err = validate_settings_for_runtime(&settings) + .expect_err("should reject a short resolved proxy secret"); + + assert!( + err.to_string().contains("at least 32 bytes"), + "error should identify the required proxy-secret strength: {err:?}" + ); + assert!( + !err.to_string().contains("short"), + "error should not expose the resolved secret" + ); + } + + #[test] + fn runtime_validation_rejects_placeholders() { let settings = Settings::from_toml( r#" [publisher] @@ -346,10 +575,10 @@ username = "admin" password = "production-admin-password-32-bytes" "#, ) - .expect("should parse placeholder settings before deploy validation"); + .expect("should parse placeholder settings before runtime validation"); - let err = - validate_settings_for_deploy(&settings).expect_err("should reject placeholder secrets"); + let err = validate_settings_for_runtime(&settings) + .expect_err("should reject placeholder secrets at runtime"); assert!( err.to_string().contains("Insecure default"), diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 6ede36e9c..fa56ca59e 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -8,20 +8,32 @@ use edgezero_core::blob_envelope::BlobEnvelope; use error_stack::Report; +use crate::config::TrustedServerAppConfig; use crate::error::TrustedServerError; +use crate::platform::{PlatformSecretStore, StoreName}; +use crate::secret_resolution::resolve_secret_references; use crate::settings::Settings; +/// Canonical logical secret store used by Trusted Server app-config secrets. +pub const DEFAULT_SECRET_STORE_ID: &str = "trusted_server_secrets"; + /// Default config-store key containing the Trusted Server app-config blob. pub const CONFIG_BLOB_KEY: &str = "trusted_server_config"; -/// Reconstruct validated [`Settings`] from a serialized config blob envelope. +/// Reconstruct runtime [`Settings`] from a serialized config blob envelope. +/// +/// Secret references are resolved after envelope verification and before +/// deserialization. The envelope data itself is never mutated or rewritten. /// /// # Errors /// /// Returns [`TrustedServerError::Configuration`] when the envelope cannot be -/// parsed, fails integrity verification, or contains invalid settings data. +/// parsed, fails integrity verification, secret resolution fails, or resolved +/// settings are invalid. pub fn settings_from_config_blob( envelope_json: &str, + secret_store: &dyn PlatformSecretStore, + default_secret_store_name: &StoreName, ) -> Result> { let envelope: BlobEnvelope = serde_json::from_str(envelope_json).map_err(|error| { Report::new(TrustedServerError::Configuration { @@ -36,14 +48,21 @@ pub fn settings_from_config_blob( .attach(error.to_string()) })?; - let settings = Settings::from_json_value(envelope.into_data())?; - settings.reject_placeholder_secrets()?; + let mut data = envelope.into_data(); + resolve_secret_references::( + &mut data, + secret_store, + default_secret_store_name, + )?; + let settings = Settings::from_json_value(data)?; + crate::config::validate_settings_for_runtime(&settings)?; Ok(settings) } #[cfg(test)] mod tests { use super::*; + use crate::platform::{PlatformError, StoreId}; use crate::redacted::Redacted; use crate::test_support::tests::crate_test_settings_str; use serde::Deserialize; @@ -69,7 +88,40 @@ mod tests { } fn test_settings() -> Settings { - Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings") + let mut settings = + Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); + settings.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; + settings + } + + struct EchoSecretStore; + + impl PlatformSecretStore for EchoSecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + key: &str, + ) -> Result, Report> { + let value = match key { + "placeholder_proxy" => "change-me-proxy-secret", + "unit-test-proxy-secret" => "unit-test-proxy-secret-32-bytes-ok", + _ => key, + }; + Ok(value.as_bytes().to_vec()) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Ok(()) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Ok(()) + } } fn envelope_json(settings: &Settings) -> String { @@ -78,11 +130,19 @@ mod tests { serde_json::to_string(&envelope).expect("should serialize envelope") } + fn load_settings(envelope_json: &str) -> Result> { + settings_from_config_blob( + envelope_json, + &EchoSecretStore, + &StoreName::from("trusted_server_secrets"), + ) + } + #[test] fn payload_round_trips_through_blob_envelope() { let original = test_settings(); - let reconstructed = settings_from_config_blob(&envelope_json(&original)) - .expect("should reconstruct settings"); + let reconstructed = + load_settings(&envelope_json(&original)).expect("should reconstruct settings"); assert_eq!( reconstructed.publisher.domain, original.publisher.domain, @@ -115,7 +175,7 @@ mod tests { let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); let reconstructed = - settings_from_config_blob(&envelope_json).expect("should reconstruct legacy settings"); + load_settings(&envelope_json).expect("should reconstruct legacy settings"); assert!( reconstructed.auction.rewrite_creatives, @@ -141,7 +201,7 @@ mod tests { let mut original = test_settings(); original.auction.rewrite_creatives = false; - let reconstructed = settings_from_config_blob(&envelope_json(&original)) + let reconstructed = load_settings(&envelope_json(&original)) .expect("should reconstruct disabled rewriting"); assert!( @@ -153,12 +213,13 @@ mod tests { #[test] fn strings_that_look_like_json_scalars_round_trip_as_strings() { let mut original = test_settings(); - original.publisher.proxy_secret = Redacted::new("1234567890".to_string()); + original.publisher.proxy_secret = + Redacted::new("12345678901234567890123456789012".to_string()); original.ec.passphrase = Redacted::new("12345678901234567890123456789012".to_string()); original.handlers[0].password = Redacted::new("true".to_string()); - let reconstructed = settings_from_config_blob(&envelope_json(&original)) - .expect("should reconstruct settings"); + let reconstructed = + load_settings(&envelope_json(&original)).expect("should reconstruct settings"); assert_eq!( reconstructed.publisher.proxy_secret.expose(), @@ -177,6 +238,60 @@ mod tests { ); } + #[test] + fn runtime_validation_rejects_short_resolved_proxy_secret() { + let mut settings = test_settings(); + settings.publisher.proxy_secret = Redacted::new("short_proxy".to_owned()); + + let err = load_settings(&envelope_json(&settings)) + .expect_err("should reject a short resolved proxy secret"); + + assert!( + err.to_string().contains("at least 32 bytes"), + "error should indicate runtime validation: {err:?}" + ); + assert!( + !err.to_string().contains("short_proxy"), + "error should not expose the secret value" + ); + } + + #[test] + fn runtime_validation_rejects_short_resolved_passphrase() { + let mut settings = test_settings(); + settings.ec.passphrase = Redacted::new("short_key".to_owned()); + + let err = load_settings(&envelope_json(&settings)) + .expect_err("should reject a short resolved passphrase"); + + assert!( + err.to_string().contains("short_passphrase") || err.to_string().contains("validation"), + "error should indicate runtime validation: {err:?}" + ); + assert!( + !err.to_string().contains("short_key"), + "error should not expose the secret value" + ); + } + + #[test] + fn placeholder_rejection_happens_after_secret_resolution() { + let mut settings = test_settings(); + settings.publisher.proxy_secret = Redacted::new("placeholder_proxy".to_owned()); + + let err = load_settings(&envelope_json(&settings)) + .expect_err("should reject a placeholder resolved from the secret store"); + + assert!( + err.to_string().contains("Insecure default"), + "error should identify the insecure default: {err:?}" + ); + assert!( + !err.to_string().contains("change-me-proxy-secret"), + "error should not expose the resolved secret value" + ); + } + #[test] fn tampered_blob_hash_is_rejected() { let mut envelope: BlobEnvelope = @@ -185,7 +300,7 @@ mod tests { let tampered = serde_json::to_string(&envelope).expect("should serialize tampered envelope"); - let err = settings_from_config_blob(&tampered).expect_err("should reject hash mismatch"); + let err = load_settings(&tampered).expect_err("should reject hash mismatch"); assert!( err.to_string().contains("integrity verification"), diff --git a/crates/trusted-server-core/src/ec/registry.rs b/crates/trusted-server-core/src/ec/registry.rs index 8532de03b..847fe70c1 100644 --- a/crates/trusted-server-core/src/ec/registry.rs +++ b/crates/trusted-server-core/src/ec/registry.rs @@ -61,6 +61,68 @@ pub struct PartnerRegistry { } impl PartnerRegistry { + /// Validates partner structure without inspecting secret values. + /// + /// This is the push-time half of partner validation. API-token length, + /// placeholder, and collision checks remain in [`Self::from_config`], + /// after secret references have been resolved. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when non-secret partner + /// structure is invalid. + pub fn validate_config_for_deploy( + partners: &[EcPartner], + ) -> Result<(), Report> { + let mut source_domains = HashMap::with_capacity(partners.len()); + + for partner in partners { + let normalized_source = normalize_partner_source_domain(&partner.source_domain) + .map_err(|msg| { + Report::new(TrustedServerError::Configuration { + message: format!("ec.partners: {msg}"), + }) + })?; + + if source_domains + .insert(normalized_source.clone(), ()) + .is_some() + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("ec.partners: duplicate source_domain '{normalized_source}'"), + })); + } + + validate_rate_limits_values(partner.batch_rate_limit, partner.pull_sync_rate_limit) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!( + "ec.partners: invalid rate limits for '{normalized_source}': {error}" + ), + }) + })?; + + if partner.pull_sync_enabled { + validate_pull_sync_fields( + partner.pull_sync_url.as_deref(), + &partner.pull_sync_allowed_domains, + partner + .ts_pull_token + .as_ref() + .map(|token| token.expose().as_str()), + false, + ) + .change_context(TrustedServerError::Configuration { + message: format!( + "ec.partners: pull sync config invalid for '{normalized_source}'" + ), + })?; + } + } + + Ok(()) + } + /// Builds a registry from the config-defined partner list. /// /// # Errors @@ -231,34 +293,56 @@ fn build_partner_config( } fn validate_rate_limits(config: &PartnerConfig) -> Result<(), Report> { - if config.batch_rate_limit == 0 { - return Err(Report::new(TrustedServerError::Configuration { - message: "batch_rate_limit must be greater than 0".to_owned(), - })); + validate_rate_limits_values(config.batch_rate_limit, config.pull_sync_rate_limit).map_err( + |message| { + Report::new(TrustedServerError::Configuration { + message: message.to_owned(), + }) + }, + ) +} + +fn validate_rate_limits_values( + batch_rate_limit: u32, + pull_sync_rate_limit: u32, +) -> Result<(), &'static str> { + if batch_rate_limit == 0 { + return Err("batch_rate_limit must be greater than 0"); } - if config.pull_sync_rate_limit == 0 { - return Err(Report::new(TrustedServerError::Configuration { - message: "pull_sync_rate_limit must be greater than 0".to_owned(), - })); + if pull_sync_rate_limit == 0 { + return Err("pull_sync_rate_limit must be greater than 0"); } Ok(()) } fn validate_pull_sync(config: &PartnerConfig) -> Result<(), Report> { - let url_str = config.pull_sync_url.as_deref().unwrap_or(""); + validate_pull_sync_fields( + config.pull_sync_url.as_deref(), + &config.pull_sync_allowed_domains, + config + .ts_pull_token + .as_ref() + .map(|token| token.expose().as_str()), + true, + ) +} + +fn validate_pull_sync_fields( + url: Option<&str>, + allowed_domains: &[String], + token_value: Option<&str>, + require_nonempty_token: bool, +) -> Result<(), Report> { + let url_str = url.unwrap_or(""); if url_str.is_empty() { return Err(Report::new(TrustedServerError::Configuration { message: "pull_sync_url is required when pull_sync_enabled is true".to_owned(), })); } - if config - .ts_pull_token - .as_ref() - .is_none_or(|token| token.expose().trim().is_empty()) - { + if token_value.is_none() { return Err(Report::new(TrustedServerError::Configuration { message: "ts_pull_token is required when pull_sync_enabled is true".to_owned(), })); @@ -289,7 +373,7 @@ fn validate_pull_sync(config: &PartnerConfig) -> Result<(), Report Result<(), Report( + data: &mut Value, + secret_store: &dyn PlatformSecretStore, + default_store_name: &StoreName, +) -> Result<(), Report> { + for field in C::secret_fields() { + if matches!(field.kind, SecretKind::StoreRef) { + continue; + } + resolve_field( + data, + &field, + &field.path, + "", + secret_store, + default_store_name, + )?; + } + Ok(()) +} + +fn resolve_field( + node: &mut Value, + field: &SecretField, + remaining: &[SecretPathSegment], + rendered_path: &str, + secret_store: &dyn PlatformSecretStore, + default_store_name: &StoreName, +) -> Result<(), Report> { + match remaining.split_first() { + Some((SecretPathSegment::Field(name), [])) => resolve_leaf( + node, + field, + name.as_ref(), + rendered_path, + secret_store, + default_store_name, + ), + Some((SecretPathSegment::Field(name), rest)) => { + let next_path = join_field(rendered_path, name.as_ref()); + let child = node + .as_object_mut() + .and_then(|object| object.get_mut(name.as_ref())) + .ok_or_else(|| missing_path(&next_path))?; + if child.is_null() { + return Err(missing_path(&next_path)); + } + resolve_field( + child, + field, + rest, + &next_path, + secret_store, + default_store_name, + ) + } + Some((SecretPathSegment::ArrayEach, rest)) => { + let items = node.as_array_mut().ok_or_else(|| { + configuration_error(format!("expected an array at `{rendered_path}`")) + })?; + for (index, item) in items.iter_mut().enumerate() { + let indexed_path = format!("{rendered_path}[{index}]"); + resolve_field( + item, + field, + rest, + &indexed_path, + secret_store, + default_store_name, + )?; + } + Ok(()) + } + None => Ok(()), + } +} + +fn resolve_leaf( + parent: &mut Value, + field: &SecretField, + key: &str, + rendered_parent: &str, + secret_store: &dyn PlatformSecretStore, + default_store_name: &StoreName, +) -> Result<(), Report> { + let leaf_path = join_field(rendered_parent, key); + let object = parent.as_object_mut().ok_or_else(|| { + configuration_error(format!("expected an object containing `{leaf_path}`")) + })?; + + let key_name = match object.get(key) { + Some(Value::String(value)) if !value.is_empty() => value.clone(), + Some(Value::Null) | None if field.optional => return Ok(()), + Some(Value::String(_)) => { + return Err(configuration_error(format!( + "secret key reference at `{leaf_path}` must not be empty" + ))); + } + _ => { + return Err(configuration_error(format!( + "secret key reference at `{leaf_path}` must be a string" + ))); + } + }; + + let resolved = secret_store + .get_string(default_store_name, &key_name) + .map_err(|_| { + configuration_error(format!( + "failed to resolve secret reference at `{leaf_path}`" + )) + })?; + if resolved.is_empty() { + return Err(configuration_error(format!( + "resolved secret at `{leaf_path}` must not be empty" + ))); + } + + object.insert(key.to_owned(), Value::String(resolved)); + Ok(()) +} + +fn join_field(prefix: &str, field: &str) -> String { + if prefix.is_empty() { + field.to_owned() + } else { + format!("{prefix}.{field}") + } +} + +fn missing_path(path: &str) -> Report { + configuration_error(format!("missing required secret path `{path}`")) +} + +fn configuration_error(message: String) -> Report { + Report::new(TrustedServerError::Configuration { message }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::{PlatformError, StoreId}; + use std::collections::BTreeMap; + + struct MemorySecretStore { + values: BTreeMap>, + } + + impl PlatformSecretStore for MemorySecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + key: &str, + ) -> Result, Report> { + self.values.get(key).cloned().ok_or_else(|| { + Report::new(PlatformError::SecretStore).attach("missing test secret") + }) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Ok(()) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Ok(()) + } + } + + struct Fixture; + + impl AppConfigMeta for Fixture { + fn secret_fields() -> Vec { + vec![ + SecretField { + kind: SecretKind::KeyInDefault, + optional: false, + path: vec![ + SecretPathSegment::Field("outer".into()), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field("token".into()), + ], + }, + SecretField { + kind: SecretKind::KeyInDefault, + optional: true, + path: vec![ + SecretPathSegment::Field("outer".into()), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field("optional".into()), + ], + }, + ] + } + } + + fn store() -> MemorySecretStore { + MemorySecretStore { + values: BTreeMap::from([ + ("token-a".to_owned(), b"resolved-a".to_vec()), + ("token-b".to_owned(), b"resolved-b".to_vec()), + ]), + } + } + + #[test] + fn resolves_nested_array_values_and_skips_optional_nulls() { + let mut data = serde_json::json!({ + "outer": [ + {"token": "token-a", "optional": null}, + {"token": "token-b"} + ] + }); + + resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")) + .expect("should resolve nested array secrets"); + + assert_eq!(data["outer"][0]["token"], "resolved-a"); + assert_eq!(data["outer"][1]["token"], "resolved-b"); + assert!(data["outer"][0]["optional"].is_null()); + } + + #[test] + fn rejects_missing_required_path_without_secret_values() { + let mut data = serde_json::json!({"outer": [{}]}); + let err = + resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")) + .expect_err("should reject missing required secret path"); + + assert!(err.to_string().contains("outer[0].token")); + assert!(!err.to_string().contains("resolved-a")); + } + + #[test] + fn rejects_malformed_array_path_without_resolving_values() { + let mut data = serde_json::json!({"outer": {"token": "token-a"}}); + let err = + resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")) + .expect_err("should reject a non-array intermediate path"); + + assert!(err.to_string().contains("expected an array")); + assert!(!err.to_string().contains("resolved-a")); + } + + #[test] + fn rejects_invalid_utf8_and_empty_resolved_values() { + let mut invalid = store(); + invalid.values.insert("token-a".to_owned(), vec![0xff]); + let mut data = serde_json::json!({"outer": [{"token": "token-a"}]}); + let err = + resolve_secret_references::(&mut data, &invalid, &StoreName::from("secrets")) + .expect_err("should reject invalid UTF-8"); + assert!(err.to_string().contains("outer[0].token")); + + let empty = MemorySecretStore { + values: BTreeMap::from([("token-a".to_owned(), Vec::new())]), + }; + let mut data = serde_json::json!({"outer": [{"token": "token-a"}]}); + let err = + resolve_secret_references::(&mut data, &empty, &StoreName::from("secrets")) + .expect_err("should reject empty resolved value"); + assert!(err.to_string().contains("outer[0].token")); + } + + #[test] + fn does_not_mutate_data_when_resolution_fails() { + let mut data = serde_json::json!({"outer": [{"token": "missing"}]}); + let original = data.clone(); + let result = + resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")); + assert!(result.is_err(), "should fail for missing secret key"); + assert_eq!(data, original, "should preserve unresolved data on failure"); + } +} diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 598c00056..91aa7ff62 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2015,19 +2015,25 @@ impl Settings { Self::finalize_deserialized(settings, "Build-time configuration") } + pub(crate) fn normalize_deserialized(&mut self) { + self.proxy.normalize(); + self.image_optimizer.normalize(); + self.consent.validate(); + } + pub(crate) fn finalize_deserialized( mut settings: Self, validation_label: &str, ) -> Result> { - settings.proxy.normalize(); - settings.image_optimizer.normalize(); - settings.consent.validate(); - + settings.normalize_deserialized(); settings.prepare_runtime()?; settings.validate().map_err(|err| { Report::new(TrustedServerError::Configuration { - message: format!("{validation_label} validation failed: {err}"), + message: format!( + "{validation_label} validation failed: {}", + validation_error_summary(&err) + ), }) })?; @@ -2235,7 +2241,7 @@ impl Settings { /// /// Returns [`TrustedServerError::Configuration`] listing any uncovered /// admin endpoints. - fn validate_admin_coverage(&self) -> Result<(), Report> { + pub(crate) fn validate_admin_coverage(&self) -> Result<(), Report> { let uncovered = self.uncovered_admin_endpoints()?; if uncovered.is_empty() { return Ok(()); @@ -2250,7 +2256,9 @@ impl Settings { })) } - fn validate_admin_handler_passwords(&self) -> Result<(), Report> { + pub(crate) fn validate_admin_handler_passwords( + &self, + ) -> Result<(), Report> { for handler in &self.handlers { let covers_admin = Self::ADMIN_ENDPOINTS .iter() @@ -2351,6 +2359,47 @@ fn validate_host_header_override(value: &str) -> Result<(), ValidationError> { Ok(()) } +fn validation_error_summary(errors: &validator::ValidationErrors) -> String { + fn walk(errors: &validator::ValidationErrors, prefix: &str, messages: &mut Vec) { + let mut fields = errors + .errors() + .keys() + .map(AsRef::as_ref) + .collect::>(); + fields.sort_unstable(); + + for field in fields { + let path = if prefix.is_empty() { + field.to_owned() + } else { + format!("{prefix}.{field}") + }; + let Some(kind) = errors.errors().get(field) else { + continue; + }; + match kind { + validator::ValidationErrorsKind::Field(validations) => { + for validation in validations { + messages.push(format!("{path}: {}", validation.code)); + } + } + validator::ValidationErrorsKind::Struct(inner) => { + walk(inner, &path, messages); + } + validator::ValidationErrorsKind::List(items) => { + for (index, inner) in items { + walk(inner, &format!("{path}[{index}]"), messages); + } + } + } + } + } + + let mut messages = Vec::new(); + walk(errors, "", &mut messages); + messages.join(", ") +} + fn validate_redacted_not_empty(value: &Redacted) -> Result<(), ValidationError> { if value.expose().is_empty() { return Err(ValidationError::new("empty_value")); diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 06ea548fc..bec1e4ad3 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -3,9 +3,10 @@ use error_stack::{Report, ResultExt}; use serde::Deserialize; use sha2::{Digest as _, Sha256}; +use crate::config_payload::DEFAULT_SECRET_STORE_ID; use crate::config_payload::settings_from_config_blob; use crate::error::TrustedServerError; -use crate::platform::{PlatformConfigStore, StoreName}; +use crate::platform::{PlatformConfigStore, PlatformSecretStore, StoreName}; use crate::settings::Settings; const DEFAULT_CONFIG_STORE_ID: &str = "trusted_server_config"; @@ -40,21 +41,29 @@ pub fn default_config_key() -> String { EnvConfig::from_env().store_key("config", DEFAULT_CONFIG_STORE_ID) } +/// Returns the default `EdgeZero` secret-store name for Trusted Server secrets. +#[must_use] +pub fn default_secret_store_name() -> StoreName { + StoreName::from(EnvConfig::from_env().store_name("secrets", DEFAULT_SECRET_STORE_ID)) +} + /// Loads [`Settings`] from a platform config store and key. /// /// # Errors /// /// Returns [`TrustedServerError::Configuration`] when the config blob is -/// missing, cannot be read, fails envelope verification, or fails Trusted -/// Server settings validation. +/// missing, cannot be read, fails envelope verification, secret resolution, +/// or Trusted Server settings validation. pub fn get_settings_from_config_store( config_store: &dyn PlatformConfigStore, + secret_store: &dyn PlatformSecretStore, store_name: &StoreName, key: &str, + default_secret_store_name: &StoreName, ) -> Result> { let raw_value = read_config_entry(config_store, store_name, key)?; let envelope_json = resolve_fastly_chunk_pointer(config_store, store_name, &raw_value)?; - settings_from_config_blob(&envelope_json) + settings_from_config_blob(&envelope_json, secret_store, default_secret_store_name) } fn read_config_entry( @@ -177,7 +186,7 @@ fn configuration_error(message: String) -> Result Result<(), Report> { Ok(()) } - fn delete( + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Ok(()) + } + } + + struct EchoSecretStore; + + impl PlatformSecretStore for EchoSecretStore { + fn get_bytes( &self, - _store_id: &crate::platform::StoreId, - _key: &str, + _store_name: &StoreName, + key: &str, + ) -> Result, Report> { + let value = match key { + "unit-test-proxy-secret" => "unit-test-proxy-secret-32-bytes-ok", + _ => key, + }; + Ok(value.as_bytes().to_vec()) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, ) -> Result<(), Report> { Ok(()) } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Ok(()) + } } fn envelope_json(settings: &Settings) -> String { @@ -219,18 +253,32 @@ mod tests { serde_json::to_string(&envelope).expect("should serialize envelope") } + fn load_settings( + config_store: &dyn PlatformConfigStore, + store_name: &StoreName, + key: &str, + ) -> Result> { + get_settings_from_config_store( + config_store, + &EchoSecretStore, + store_name, + key, + &StoreName::from("trusted_server_secrets"), + ) + } + #[test] fn loads_settings_from_config_blob_entry() { - let settings = + let mut settings = Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); + settings.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; let envelope_json = envelope_json(&settings); let store = MemoryConfigStore { entries: BTreeMap::from([(CONFIG_BLOB_KEY.to_string(), envelope_json)]), }; - let loaded = - get_settings_from_config_store(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) - .expect("should load settings"); + let loaded = load_settings(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) + .expect("should load settings"); assert_eq!( loaded.publisher.domain, settings.publisher.domain, @@ -240,8 +288,9 @@ mod tests { #[test] fn loads_settings_from_fastly_chunk_pointer() { - let settings = + let mut settings = Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); + settings.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; let envelope_json = envelope_json(&settings); let midpoint = envelope_json.len() / 2; let first_chunk = envelope_json[..midpoint].to_string(); @@ -275,9 +324,8 @@ mod tests { ]), }; - let loaded = - get_settings_from_config_store(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) - .expect("should load settings"); + let loaded = load_settings(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) + .expect("should load settings"); assert_eq!( loaded.publisher.domain, settings.publisher.domain, @@ -306,9 +354,8 @@ mod tests { entries: BTreeMap::from([(CONFIG_BLOB_KEY.to_string(), pointer)]), }; - let err = - get_settings_from_config_store(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) - .expect_err("should reject malformed chunk length metadata"); + let err = load_settings(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) + .expect_err("should reject malformed chunk length metadata"); assert!( err.to_string().contains("chunk lengths total mismatch"), @@ -322,9 +369,8 @@ mod tests { entries: BTreeMap::new(), }; - let err = - get_settings_from_config_store(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) - .expect_err("should fail when blob is missing"); + let err = load_settings(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) + .expect_err("should fail when blob is missing"); assert!( err.to_string().contains(CONFIG_BLOB_KEY), diff --git a/crates/trusted-server-integration-tests/Cargo.toml b/crates/trusted-server-integration-tests/Cargo.toml index f2319fec8..7477fdbd1 100644 --- a/crates/trusted-server-integration-tests/Cargo.toml +++ b/crates/trusted-server-integration-tests/Cargo.toml @@ -23,6 +23,7 @@ workspace = true [dependencies] edgezero-core = { workspace = true } serde_json = { workspace = true } +toml = { workspace = true } trusted-server-core = { workspace = true } [dev-dependencies] @@ -40,7 +41,6 @@ reqwest = { workspace = true, features = ["blocking", "cookies"] } scraper = { workspace = true } testcontainers = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } -toml = { workspace = true } tower = { workspace = true, features = ["util"] } trusted-server-adapter-axum = { path = "../trusted-server-adapter-axum" } trusted-server-adapter-cloudflare = { path = "../trusted-server-adapter-cloudflare" } diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index 17d7c2713..2a3dec32d 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -1,16 +1,16 @@ [[handlers]] path = "^/_ts/admin" username = "admin" -password = "integration-admin-password-32-bytes-ok" +password = "integration_admin_password" [publisher] domain = "localhost" cookie_domain = "localhost" origin_url = "http://127.0.0.1:8888" -proxy_secret = "integration-test-proxy-secret" +proxy_secret = "integration_proxy_secret" [ec] -passphrase = "integration-test-ec-secret-padded-32" +passphrase = "integration_ec_passphrase" ec_store = "ec_identity_store" pull_sync_concurrency = 3 @@ -18,13 +18,13 @@ pull_sync_concurrency = 3 name = "Integration Test Partner" source_domain = "inttest.example.com" bidstream_enabled = true -api_token = "integration-test-token-alpha-32-bytes-ok" +api_token = "integration_partner_token_alpha" [[ec.partners]] name = "Integration Test Partner 2" source_domain = "inttest2.example.com" bidstream_enabled = true -api_token = "integration-test-token-bravo-32-bytes-ok" +api_token = "integration_partner_token_bravo" [request_signing] enabled = false diff --git a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml index 9f1443d20..aa025b6c7 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml @@ -66,6 +66,22 @@ key = "api_key" data = "test-api-key" + [[local_server.secret_stores.trusted_server_secrets]] + key = "integration_admin_password" + data = "integration-admin-password-32-bytes-ok" + [[local_server.secret_stores.trusted_server_secrets]] + key = "integration_proxy_secret" + data = "integration-test-proxy-secret-32-bytes-ok" + [[local_server.secret_stores.trusted_server_secrets]] + key = "integration_ec_passphrase" + data = "integration-test-ec-secret-padded-32" + [[local_server.secret_stores.trusted_server_secrets]] + key = "integration_partner_token_alpha" + data = "integration-test-token-alpha-32-bytes-ok" + [[local_server.secret_stores.trusted_server_secrets]] + key = "integration_partner_token_bravo" + data = "integration-test-token-bravo-32-bytes-ok" + [local_server.config_stores] # Generated integration configs inject the trusted_server_config blob # into the store required by the Fastly entry point. diff --git a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs index 85b1bcf0f..58c26736e 100644 --- a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs +++ b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs @@ -4,7 +4,7 @@ use std::fs; use std::path::PathBuf; use edgezero_core::blob_envelope::BlobEnvelope; -use trusted_server_core::{config::validate_settings_for_deploy, settings::Settings}; +use trusted_server_core::config::TrustedServerAppConfig; const GENERATED_AT: &str = "2026-06-23T00:00:00Z"; const GENERATED_STORES_MARKER: &str = " # GENERATED_TRUSTED_SERVER_CONFIG_STORES"; @@ -114,15 +114,16 @@ fn build_app_config_envelope( app_config_toml: &str, origin_url: Option<&str>, ) -> Result { - let mut settings = Settings::from_toml(app_config_toml) - .map_err(|report| error_box(format!("invalid Trusted Server app config: {report:?}")))?; + let app_config: TrustedServerAppConfig = toml::from_str(app_config_toml) + .map_err(|error| error_box(format!("invalid Trusted Server app config: {error}")))?; + let mut settings = app_config.into_settings(); if let Some(origin_url) = origin_url { settings.publisher.origin_url = origin_url.to_string(); } - validate_settings_for_deploy(&settings) + let app_config = TrustedServerAppConfig::new(settings) .map_err(|report| error_box(format!("invalid Trusted Server app config: {report:?}")))?; - let data = serde_json::to_value(&settings).map_err(|error| { + let data = serde_json::to_value(&app_config).map_err(|error| { error_box(format!( "failed to serialize Trusted Server app config to JSON: {error}" )) @@ -161,11 +162,71 @@ fn error_box(message: impl Into) -> DynError { #[cfg(test)] mod tests { use super::*; + use error_stack::Report; + use std::collections::HashMap; use trusted_server_core::config_payload::settings_from_config_blob; + use trusted_server_core::platform::{PlatformError, PlatformSecretStore, StoreId, StoreName}; const TEMPLATE: &str = include_str!("../../fixtures/configs/viceroy-template.toml"); const APP_CONFIG: &str = include_str!("../../fixtures/configs/trusted-server.integration.toml"); + struct IntegrationSecretStore { + values: HashMap>, + } + + impl PlatformSecretStore for IntegrationSecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + key: &str, + ) -> Result, Report> { + self.values + .get(key) + .cloned() + .ok_or_else(|| Report::new(PlatformError::SecretStore)) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Ok(()) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Ok(()) + } + } + + fn integration_secret_store() -> IntegrationSecretStore { + IntegrationSecretStore { + values: HashMap::from([ + ( + "integration_admin_password".to_owned(), + b"integration-admin-password-32-bytes-ok".to_vec(), + ), + ( + "integration_proxy_secret".to_owned(), + b"integration-test-proxy-secret-32-bytes-ok".to_vec(), + ), + ( + "integration_ec_passphrase".to_owned(), + b"integration-test-ec-secret-padded-32".to_vec(), + ), + ( + "integration_partner_token_alpha".to_owned(), + b"integration-test-token-alpha-32-bytes-ok".to_vec(), + ), + ( + "integration_partner_token_bravo".to_owned(), + b"integration-test-token-bravo-32-bytes-ok".to_vec(), + ), + ]), + } + } + #[test] fn parse_args_does_not_require_removed_rollout_switch() { let result = parse_args([ @@ -253,7 +314,12 @@ mod tests { fn generated_blob_verifies_and_applies_origin_override() { let envelope = build_app_config_envelope(APP_CONFIG, Some("http://127.0.0.1:9999")) .expect("should build envelope"); - let settings = settings_from_config_blob(&envelope).expect("should verify blob"); + let settings = settings_from_config_blob( + &envelope, + &integration_secret_store(), + &StoreName::from("trusted_server_secrets"), + ) + .expect("should verify blob"); assert_eq!( settings.publisher.origin_url, "http://127.0.0.1:9999", @@ -268,6 +334,19 @@ mod tests { assert!(result.is_err(), "should reject invalid app config"); } + #[test] + fn invalid_non_secret_app_config_fails_before_envelope_generation() { + let invalid = APP_CONFIG.replace("domain = \"localhost\"", "domain = \"invalid/domain\""); + + let err = build_app_config_envelope(&invalid, None) + .expect_err("should reject invalid non-secret config before creating an envelope"); + + assert!( + err.to_string().contains("invalid_publisher_domain"), + "error should identify the structural validation failure: {err}" + ); + } + #[test] fn missing_marker_fails() { let result = inject_generated_config_stores("[local_server]", "{}"); diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs index 4dc971d0e..037fa4658 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -1,7 +1,6 @@ use edgezero_core::blob_envelope::BlobEnvelope; use error_stack::Report; -use trusted_server_core::config::validate_settings_for_deploy; -use trusted_server_core::settings::Settings; +use trusted_server_core::config::TrustedServerAppConfig; use crate::common::runtime::{TestError, TestResult}; @@ -10,18 +9,19 @@ const APP_CONFIG: &str = include_str!("../../fixtures/configs/trusted-server.int pub fn integration_app_config_envelope(origin_port: u16) -> TestResult { let origin_url = format!("http://127.0.0.1:{origin_port}"); - let mut settings = Settings::from_toml(APP_CONFIG).map_err(|report| { + let app_config: TrustedServerAppConfig = toml::from_str(APP_CONFIG).map_err(|error| { Report::new(TestError::ConfigGeneration).attach(format!( - "invalid Trusted Server integration config: {report:?}" + "invalid Trusted Server integration config: {error}" )) })?; + let mut settings = app_config.into_settings(); settings.publisher.origin_url = origin_url; - validate_settings_for_deploy(&settings).map_err(|report| { + let app_config = TrustedServerAppConfig::new(settings).map_err(|report| { Report::new(TestError::ConfigGeneration) .attach(format!("invalid generated integration config: {report:?}")) })?; - let data = serde_json::to_value(&settings).map_err(|error| { + let data = serde_json::to_value(&app_config).map_err(|error| { Report::new(TestError::ConfigGeneration) .attach(format!("failed to serialize integration settings: {error}")) })?; diff --git a/crates/trusted-server-integration-tests/tests/environments/axum.rs b/crates/trusted-server-integration-tests/tests/environments/axum.rs index 235af413f..3623d8491 100644 --- a/crates/trusted-server-integration-tests/tests/environments/axum.rs +++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs @@ -10,6 +10,30 @@ use std::process::{Child, Command, Stdio}; /// Default port the Axum dev server binds to when no `PORT` env var is supplied. const AXUM_DEFAULT_PORT: u16 = 8787; +/// Secret-store entries referenced by the integration app-config fixture. +const INTEGRATION_SECRET_ENV: &[(&str, &str)] = &[ + ( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_ADMIN_PASSWORD", + "integration-admin-password-32-bytes-ok", + ), + ( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_PROXY_SECRET", + "integration-test-proxy-secret-32-bytes-ok", + ), + ( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_EC_PASSPHRASE", + "integration-test-ec-secret-padded-32", + ), + ( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_PARTNER_TOKEN_ALPHA", + "integration-test-token-alpha-32-bytes-ok", + ), + ( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_PARTNER_TOKEN_BRAVO", + "integration-test-token-bravo-32-bytes-ok", + ), +]; + /// Axum native dev-server runtime environment. /// /// Spawns the pre-built `trusted-server-axum` binary directly (no WASM, no @@ -40,6 +64,7 @@ impl RuntimeEnvironment for AxumDevServer { "TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG", app_config, ) + .envs(INTEGRATION_SECRET_ENV.iter().copied()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ddb6544ce..434d4878d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -6,9 +6,9 @@ Learn how to configure Trusted Server for your deployment. Trusted Server uses a flexible configuration system based on: -1. **TOML Files** - `trusted-server.toml` for base configuration +1. **TOML Files** - `trusted-server.toml` for ordinary configuration and secret key names 2. **Environment Variables** - Typed CLI overrides with the `TRUSTED_SERVER__` prefix -3. **Fastly Stores** - KV/Config/Secret stores for runtime data +3. **EdgeZero Stores** - Config and secret stores for the pushed blob and runtime secret values ## Quick Start @@ -21,10 +21,10 @@ Create `trusted-server.toml` in your project root: domain = "publisher.com" cookie_domain = ".publisher.com" origin_url = "https://origin.publisher.com" -proxy_secret = "your-secure-secret-here" +proxy_secret = "publisher_proxy_secret" [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +passphrase = "ec_passphrase" ``` ### Environment Variable Overrides @@ -37,16 +37,56 @@ read by the deployed application at request time. # Format: TRUSTED_SERVER__SECTION__FIELD export TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com -export TRUSTED_SERVER__EC__PASSPHRASE=replace-with-32-plus-byte-random-secret +# Secret overrides, when needed, are key names—not secret values. +export TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=publisher_proxy_secret +export TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase ts config validate ts config push --adapter fastly ``` +### Secret-store migration + +The five app-config secret fields contain stable key names only: +`publisher.proxy_secret`, `ec.passphrase`, `ec.partners[*].api_token`, +`ec.partners[*].ts_pull_token` (when used), and `handlers[*].password`. +Their values belong in the logical `trusted_server_secrets` store and are +resolved only while an instance builds runtime settings. + +Migrate an existing deployment in this order: + +1. Create/populate `trusted_server_secrets` with the existing credential values + without printing them in shell history, logs, or CI output. +2. Replace the five config values with stable key names. +3. Run `ts config validate`, then `ts config push --adapter fastly --no-diff`. +4. Restart/redeploy instances as needed to load the new values. Rotation is + startup-scoped; changing a store value does not alter already-built state. + +`--no-diff` prevents `config push` from rendering the previous plaintext +configuration during this migration. + +Keep `publisher.proxy_secret` and `ec.passphrase` stable unless intentionally +rotating signed URLs or EC identifiers. On Spin, the app-config blob is stored +under the `trusted_server_config` key in Spin's built-in `default` key-value +store. Set the corresponding CLI store mapping before pushing so the write +matches the runtime lookup: + +```bash +export EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=default +ts config push --adapter spin +``` + +For local Spin development, add `--local` to the push command. Also declare a +component variable for each chosen secret key name using the encoder documented +in `spin.toml`. Missing stores, keys, invalid UTF-8, and empty values fail +closed; inline plaintext fallback is not supported. + ### Generate Secure Secrets +Generate values locally and write them directly to the platform secret store; +do not put the generated output in `trusted-server.toml` or the app-config blob. + ```bash -# Generate cryptographically random secrets openssl rand -base64 32 ``` @@ -84,10 +124,10 @@ fail and the service will return its startup-error response. domain = "publisher.com" cookie_domain = ".publisher.com" origin_url = "https://origin.publisher.com" -proxy_secret = "change-me-to-secure-value" +proxy_secret = "publisher_proxy_secret" [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +passphrase = "ec_passphrase" [request_signing] enabled = true @@ -114,9 +154,10 @@ base TOML configuration by `ts config validate`, `ts config diff`, and stored in the app-config blob. Changing an environment variable requires rerunning validation and pushing the resolved config, not rebuilding the binary. -EdgeZero v0.0.4 only overrides leaves that already exist in the parsed TOML; it -does not create missing fields. Add newly introduced defaulted fields to an -existing config before relying on their environment overrides. Pass `--no-env` +The pinned EdgeZero loader only overrides leaves that already exist in the +parsed TOML; it does not create missing fields. Add newly introduced defaulted +fields to an existing config before relying on their environment overrides. +Secret overlays still contain key names, never secret values. Pass `--no-env` to use file values without the overlay. ### Format @@ -177,7 +218,7 @@ Core publisher settings for domain, origin, and proxy configuration. | `cookie_domain` | String | Yes | Domain for non-EC cookies (typically with leading dot) | | `origin_url` | String | Yes | Full URL of publisher origin server | | `origin_host_header_override` | String | No | Outbound Host header to send while connecting to `origin_url` | -| `proxy_secret` | String | Yes | Secret key for encrypting/signing proxy URLs | +| `proxy_secret` | String | Yes | Secret-store key name for the proxy URL secret | | `max_buffered_body_bytes` | Integer | No | Buffered-body cap / Fastly stream raw+decoded byte ceiling (default 16 MiB) | > **Note:** EC cookies (`ts-ec`) derive their domain automatically as `.{domain}` and @@ -192,7 +233,7 @@ cookie_domain = ".publisher.com" origin_url = "https://origin.publisher.com" # Optional: connect to origin_url but send this outbound Host header. # origin_host_header_override = "www.publisher.com" -proxy_secret = "change-me-to-secure-random-value" +proxy_secret = "publisher_proxy_secret" ``` **Environment Override**: @@ -202,7 +243,7 @@ TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com TRUSTED_SERVER__PUBLISHER__COOKIE_DOMAIN=.publisher.com TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com TRUSTED_SERVER__PUBLISHER__ORIGIN_HOST_HEADER_OVERRIDE=www.publisher.com -TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=your-secret-here +TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=publisher_proxy_secret TRUSTED_SERVER__PUBLISHER__MAX_BUFFERED_BODY_BYTES=16777216 ``` @@ -281,21 +322,12 @@ connecting to the host in `origin_url`. #### `proxy_secret` -**Purpose**: Secret key for HMAC-SHA256 signing of proxy URLs. - -**Security**: - -- Keep confidential and secure -- Rotate periodically (90 days recommended) -- Use cryptographically random values (32+ bytes) -- Never commit to version control +**Purpose**: Secret-store key name for the HMAC-SHA256 value used to sign proxy URLs. -**Generation**: - -```bash -# Generate secure random secret -openssl rand -base64 32 -``` +The referenced value is resolved from `trusted_server_secrets` at startup. It +must be at least 32 bytes, so generate it with a cryptographically secure random +source. Keep that value confidential, rotate it only intentionally, and never +put it in the TOML file or pushed app-config blob. **Usage**: @@ -404,6 +436,9 @@ Settings for Edge Cookie identifier generation. The `ec_store` KV store is the o ### `[ec]` +`passphrase` is a key name in `trusted_server_secrets`; the resolved value must +be at least 32 bytes. Keep it stable to preserve EC identifier continuity. + | Field | Type | Required | Description | | ------------------------- | -------------- | -------- | ----------------------------------------------------------------------- | | `passphrase` | String | Yes | Publisher passphrase used as HMAC key | @@ -421,20 +456,21 @@ Settings for Edge Cookie identifier generation. The `ec_store` KV store is the o ```toml [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +passphrase = "ec_passphrase" ec_store = "ec_identity_store" [[ec.partners]] name = "Mocktioneer SSP" source_domain = "mocktioneer.example" -api_token = "partner-api-token-32-bytes-minimum" +api_token = "partner_api_token" bidstream_enabled = true +# ts_pull_token = "partner_ts_pull_token" # only when pull sync is enabled ``` **Environment Override**: ```bash -TRUSTED_SERVER__EC__PASSPHRASE=your-secret +TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase TRUSTED_SERVER__EC__EC_STORE=ec_identity_store ``` @@ -442,20 +478,13 @@ TRUSTED_SERVER__EC__EC_STORE=ec_identity_store #### `passphrase` -**Purpose**: Publisher passphrase used as HMAC key for EC ID generation. +**Purpose**: Secret-store key name whose resolved value is the HMAC key for EC ID generation. **Security**: -- Must be non-empty -- Rotate periodically for security -- Store securely (environment variable recommended) - -**Generation**: - -```bash -# Generate secure random key -openssl rand -hex 32 -``` +- The key name is stored in app config; the value is stored in `trusted_server_secrets` +- Keep the value stable unless intentionally rotating EC identifiers +- Do not place the value in environment overlays or the pushed blob **Validation**: Application startup fails if: @@ -592,18 +621,18 @@ Path-based HTTP Basic Authentication. [[handlers]] path = "^/_ts/admin" username = "admin" -password = "secure-password" +password = "admin_password" # Multiple handlers [[handlers]] path = "^/secure" username = "user1" -password = "pass1" +password = "secure_handler_password" [[handlers]] path = "^/api/private" username = "api-user" -password = "api-pass" +password = "api_handler_password" ``` **Environment Override**: @@ -612,12 +641,12 @@ password = "api-pass" # Handler 0 TRUSTED_SERVER__HANDLERS__0__PATH="^/_ts/admin" TRUSTED_SERVER__HANDLERS__0__USERNAME="admin" -TRUSTED_SERVER__HANDLERS__0__PASSWORD="secure-password" +TRUSTED_SERVER__HANDLERS__0__PASSWORD="admin_password" # Handler 1 TRUSTED_SERVER__HANDLERS__1__PATH="^/api/private" TRUSTED_SERVER__HANDLERS__1__USERNAME="api-user" -TRUSTED_SERVER__HANDLERS__1__PASSWORD="api-pass" +TRUSTED_SERVER__HANDLERS__1__PASSWORD="api_handler_password" ``` ### Path Patterns @@ -674,10 +703,9 @@ scheduled for removal **Password Storage**: -- Stored in plain text in config -- Use environment variables in production -- Rotate passwords regularly -- Consider using Fastly Secret Store +- `handlers[*].password` is a key name in `trusted_server_secrets` +- Store the resolved password only in the platform secret store +- Rotate passwords through the store and restart/redeploy instances **Limitations**: @@ -687,12 +715,9 @@ scheduled for removal - No rate limiting (add at edge) ::: warning Production Use -For production, store credentials in environment variables: - -```bash -TRUSTED_SERVER__HANDLERS__0__PASSWORD=$(cat /run/secrets/admin_password) -``` - +Do not put handler passwords in `trusted-server.toml`, environment overlays, or +app-config blobs. Provision the referenced key in `trusted_server_secrets` +before pushing the config. ::: ## URL Rewrite Configuration @@ -1292,7 +1317,7 @@ remove that field's non-default value (and any environment override), run `ts config validate`, push the resulting default-compatible blob, and only then roll back the binary. -**Environment overlays:** EdgeZero v0.0.4 overlays cannot create missing TOML +**Environment overlays:** The pinned EdgeZero loader cannot create missing TOML leaves. Existing configs must add **both** leaves under `[auction]` (`rewrite_creatives` and `sanitize_creatives`) before `TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES` / @@ -1506,14 +1531,15 @@ Configuration is validated at startup: **EC Validation**: -- `passphrase` ≥ 1 character -- `passphrase` ≠ known placeholders (`"secret-key"`, `"secret_key"`, `"trusted-server"` — case-insensitive) +- The `passphrase` key name is non-empty at push time +- The resolved passphrase is at least 32 bytes at runtime +- Known placeholder values are rejected after resolution **Handler Validation**: - `path` is valid regex -- `username` non-empty -- `password` non-empty +- `username` is ordinary configuration and non-empty +- The resolved `password` is non-empty and is checked for placeholders at runtime **Integration Validation**: @@ -1548,37 +1574,29 @@ server_url: must not be empty [publisher] domain = "localhost" origin_url = "http://localhost:3000" -proxy_secret = "dev-secret" -``` - -**Staging**: - -```bash -# .env.staging -TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://staging.publisher.com -TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=$(cat /run/secrets/proxy_secret_staging) +proxy_secret = "publisher_proxy_secret" ``` -**Production**: +**Staging and production**: -```bash -# All secrets from environment -TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=$(cat /run/secrets/proxy_secret) -TRUSTED_SERVER__EC__PASSPHRASE=$(cat /run/secrets/ec_secret) -TRUSTED_SERVER__HANDLERS__0__PASSWORD=$(cat /run/secrets/admin_password) -``` +- Provision the same key names in the target `trusted_server_secrets` store. +- Keep only the key names in `trusted-server.toml` and environment overlays. +- Push the config after provisioning and restart/redeploy after rotation. ### Secret Management **Do**: -✅ Use environment variables for secrets -✅ Rotate secrets periodically -✅ Generate cryptographically random values -✅ Store in secure secret management (Fastly Secret Store, Vault) -✅ Use different secrets per environment +✅ Store values in the platform secret store +✅ Rotate values deliberately and restart/redeploy instances +✅ Generate values locally without printing them to logs +✅ Use different values per environment when appropriate +✅ Keep stable key names for rotation **Don't**: -❌ Commit secrets to version control +❌ Commit secret values to version control +❌ Put secret values in environment overlays +❌ Put secret values in config diff output or app-config blobs +❌ Treat missing secret-store keys as inline values ❌ Use default/placeholder values ❌ Share secrets across environments ❌ Log secret values @@ -1618,10 +1636,10 @@ trusted-server.dev.toml # Development overrides **"Configuration field '...' is set to a known placeholder value"**: -- `ec.passphrase` cannot be `"secret-key"`, `"secret_key"`, or `"trusted-server"` (case-insensitive) -- `publisher.proxy_secret` cannot be `"change-me-proxy-secret"` (case-insensitive) -- Must be non-empty -- Change to a secure random value (see generation commands above) +- Confirm the referenced key exists in `trusted_server_secrets` +- Ensure the resolved value is non-empty and not a known placeholder +- Do not replace the key name with a plaintext value in the app config +- Rotate the value in the platform secret store, then restart/redeploy **"Invalid regex"**: @@ -1638,7 +1656,7 @@ trusted-server.dev.toml # Development overrides **Environment Variables Not Applied**: - Run the override through `ts config validate`, `ts config diff`, or `ts config push` -- Verify the target leaf already exists in `trusted-server.toml`; EdgeZero v0.0.4 does not create missing fields +- Verify the target leaf already exists in `trusted-server.toml`; the pinned EdgeZero loader does not create missing fields - Verify prefix: `TRUSTED_SERVER__` - Check separator: `__` (double underscore) - Confirm the variable is exported: `echo $VARIABLE_NAME` diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 9314f983b..760a747cc 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -65,18 +65,29 @@ The server will be available at `http://localhost:7676`. No Fastly account, CLI, or Viceroy needed. Runs natively on your machine. -The Axum adapter reads configuration from environment variables — it does **not** -auto-load `.env` files. You must export the variables into your shell before starting -the server. +The Axum adapter reads the EdgeZero config blob and secret store from +environment variables — it does **not** auto-load `.env` files. You must export +the variables into your shell before starting the server. ```bash -# Copy and edit the environment file +# Create the local app config and apply the non-secret development overlay. +cp trusted-server.example.toml trusted-server.toml cp .env.dev .env - -# Export the variables into your current shell session set -a && source .env && set +a -# Build and start the dev server +# Create the local blob-backed config-store entry. +ts config push --adapter axum --local --yes +export TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG="$( + jq -r '.trusted_server_config' .edgezero/local-config-trusted_server_config.json +)" + +# Populate the three secret references from the starter config for this shell. +# Use stable values only if you need existing proxy URLs or EC IDs to remain valid. +export TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET="$(openssl rand -base64 32)" +export TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE="$(openssl rand -base64 32)" +export TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD="$(openssl rand -base64 32)" + +# Build and start the dev server in the same shell. cargo run -p trusted-server-adapter-axum ``` @@ -85,12 +96,16 @@ The server will be available at `http://localhost:8787`. Set `PORT=` befor **Environment variable conventions used by the Axum adapter:** -| Purpose | Pattern | Example | -| ------------------ | ------------------------------------- | -------------------------------------------------------- | -| Config store value | `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` | `TRUSTED_SERVER_CONFIG_SETTINGS_AD_SERVER_URL=https://…` | -| Secret store value | `TRUSTED_SERVER_SECRET_{STORE}_{KEY}` | `TRUSTED_SERVER_SECRET_KEYS_SIGNING_KEY=abc123` | +| Purpose | Pattern | Example | +| ------------------ | ------------------------------------- | --------------------------------------------------------------------- | +| Config store value | `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` | `TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG=…` | +| Secret store value | `TRUSTED_SERVER_SECRET_{STORE}_{KEY}` | `TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PROXY_KEY=…` | -Store names and key names are uppercased with hyphens and dots replaced by underscores. +The config-store value is the verified app-config blob. Secret-store values are +looked up by the key names in that blob. Store names and key names are uppercased +with hyphens and dots replaced by underscores. The quick-start exports ephemeral +secret-store values only into the current shell; do not put secret values in the +TOML config, config-store blob, or a source-controlled environment file. > **Dev server limitations:** The Axum adapter does not support KV store, > geo lookup, config/secret-store writes, or admin key-management routes. @@ -131,7 +146,8 @@ ts audit https://publisher.example ``` The audit command writes `js-assets.toml` plus a draft `trusted-server.toml`. -Review the draft, replace placeholders/secrets, then validate it. +Review the draft, replace placeholders with stable secret key names, then +validate it. Edit `trusted-server.toml` to configure: @@ -139,14 +155,18 @@ Edit `trusted-server.toml` to configure: - KV store mappings - EC configuration - Consent settings (`[gdpr]`) +- Stable key names for `trusted_server_secrets` -Validate the config before pushing it to platform storage: +Provision `trusted_server_secrets` with the existing credential values before +pushing a migrated config. Then validate and push: ```bash ts config validate +ts config push --adapter fastly ``` -See [Configuration](/guide/configuration) and [Trusted Server CLI](/guide/cli) for details. +Restart or redeploy instances after secret rotation. See +[Configuration](/guide/configuration) and [Trusted Server CLI](/guide/cli) for details. ## Deploy to Fastly diff --git a/fastly.toml b/fastly.toml index 56002bc5a..9d44a3e10 100644 --- a/fastly.toml +++ b/fastly.toml @@ -61,6 +61,12 @@ build = """ key = "tinybird_access_append_token" data = "test-tinybird-access-append-token" + # App-config secret references resolve from this canonical logical store. + # Populate production values through the EdgeZero secret-store workflow. + [[local_server.secret_stores.trusted_server_secrets]] + key = "placeholder" + data = "placeholder" + [local_server.config_stores] [local_server.config_stores.trusted_server_config] format = "inline-toml" diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 19ecda4a5..6169559a7 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -1,7 +1,7 @@ [[handlers]] path = "^/_ts/admin" username = "admin" -password = "replace-with-admin-password-32-bytes" +password = "handler_password" [publisher] domain = "example.com" @@ -9,23 +9,29 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" # Optional: override outbound Host header while connecting to origin_url. # origin_host_header_override = "www.example.com" -proxy_secret = "change-me-proxy-secret" +proxy_secret = "publisher_proxy_secret" [ec] -passphrase = "trusted-server-placeholder-secret" +passphrase = "ec_passphrase" ec_store = "ec_identity_store" pull_sync_concurrency = 3 +# Keep this empty when no partners are configured. Replace this line with +# `[[ec.partners]]` entries when adding partners. +partners = [] # cluster_trust_threshold = 10 # cluster_recheck_secs = 3600 -# Example partner configuration. Replace the token before validating/pushing. +# Example partner configuration. Provision referenced keys in +# trusted_server_secrets before validating/pushing. # [[ec.partners]] # name = "Example Partner" # source_domain = "partner.example.com" # OpenRTB agent type; vendor-specific values are supported (PAIR uses 571187). # openrtb_atype = 3 # bidstream_enabled = true -# api_token = "replace-with-partner-api-token-32-bytes-minimum" +# api_token = "partner_api_token" +# Optional when pull sync is enabled: +# ts_pull_token = "partner_ts_pull_token" # batch_rate_limit = 60 # pull_sync_enabled = false