From 2206eac5ea865cb62692d603162a2afdcbc3130f Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 11 Aug 2026 11:47:57 +0100 Subject: [PATCH 1/3] Add stateless backend-scoped task handles Signed-off-by: lucarlig --- .secrets.baseline | 8 +- Cargo.lock | 2 + _context/wiki/routing.md | 7 + _context/wiki/security.md | 3 + crates/contextforge-data-plane-lib/Cargo.toml | 2 + .../contextforge-data-plane-lib/src/common.rs | 6 + crates/contextforge-data-plane-lib/src/lib.rs | 1 + .../src/task_handle.rs | 390 ++++++++++++++++++ 8 files changed, 415 insertions(+), 4 deletions(-) create mode 100644 crates/contextforge-data-plane-lib/src/task_handle.rs diff --git a/.secrets.baseline b/.secrets.baseline index 7cf529b9..4c30b07e 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "Cargo\\.lock$|\\.lock$|target/|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-08-11T12:22:16Z", + "generated_at": "2026-08-18T08:49:35Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -142,7 +142,7 @@ "hashed_secret": "4a4645604f0b9e29503be96a87f6f47a6e4a7890", "is_secret": false, "is_verified": false, - "line_number": 154, + "line_number": 155, "type": "Secret Keyword", "verified_result": null }, @@ -150,7 +150,7 @@ "hashed_secret": "427f5e1b530d4a544883308d876a11d724060c86", "is_secret": false, "is_verified": false, - "line_number": 157, + "line_number": 158, "type": "Secret Keyword", "verified_result": null }, @@ -158,7 +158,7 @@ "hashed_secret": "bfc6000db1195a9522813fc405c666dd4ce669ad", "is_secret": false, "is_verified": false, - "line_number": 250, + "line_number": 256, "type": "Secret Keyword", "verified_result": null } diff --git a/Cargo.lock b/Cargo.lock index a1f9d8c9..18a51d28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -615,6 +615,7 @@ dependencies = [ "axum", "axum-otel-metrics", "axum-server", + "base64 0.22.1", "chrono", "clap", "contextforge-data-plane-apis", @@ -632,6 +633,7 @@ dependencies = [ "opentelemetry_sdk", "redis", "reqwest", + "ring", "rmcp", "rmp-serde", "rustls", diff --git a/_context/wiki/routing.md b/_context/wiki/routing.md index dcba0e18..4a8654d0 100644 --- a/_context/wiki/routing.md +++ b/_context/wiki/routing.md @@ -52,6 +52,13 @@ The gateway wraps per-backend cursors inside its own opaque token (JSON, treated **Known limitation:** if backend set changes between pages, removed backend's cursor is silently dropped. +## Task Handles + +- Never expose an upstream task ID directly. +- Encode it as `cfth1.` with AES-256-GCM and a random nonce. +- Bind the payload to JWT `sub`, virtual host, and backend; reject mismatches and removed backends as `invalid task ID`. +- Handles are stateless. Replicas must share the key; key rotation invalidates outstanding handles. + ## Session State (local process) Backend RMCP services are stored in `BackendTransports` keyed by: diff --git a/_context/wiki/security.md b/_context/wiki/security.md index 5c077bac..aac94484 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -44,6 +44,7 @@ dataplane contract. | If this is compromised | Impact | | --- | --- | | JWT signing key or HMAC secret | Attacker mints tokens for any subject and reaches that subject's backends. Rotate the key and restart; no revocation exists. | +| Task-handle key | Attacker decrypts or forges upstream task routes. Rotate the key; outstanding handles become invalid. | | Redis write access | Attacker rewrites routing (arbitrary backend URLs receive caller traffic) and, if runtime plugins are enabled, chooses which registered hooks run on payloads. Protect Redis with TLS/mTLS and control-plane-only write access. | | A backend MCP server | Attacker sees requests routed to that backend and controls its responses; the namespace prefix limits blast radius to that backend's objects. | | The gateway process | Full compromise: it holds the decoding keys in memory and live backend sessions. | @@ -83,4 +84,6 @@ These routes are registered **outside the authentication middleware** — unauth ## Secrets Handling - The HMAC secret is held as a `SecretString`; key and certificate material is read from disk paths at startup. +- Task handles are authenticated, encrypted, and scoped to JWT `sub` + virtual host + backend. They do not replace JWT validation. - Never log: tokens, authorization headers, secrets, Redis key/value bytes, full `UserConfig` documents, or backend credentials. +- Treat task handles as opaque; do not log them. diff --git a/crates/contextforge-data-plane-lib/Cargo.toml b/crates/contextforge-data-plane-lib/Cargo.toml index 6505887b..72400621 100644 --- a/crates/contextforge-data-plane-lib/Cargo.toml +++ b/crates/contextforge-data-plane-lib/Cargo.toml @@ -46,6 +46,8 @@ tokio-rustls = "0.26.4" typed-builder = "0.23.2" url = { workspace = true, features = ["serde"] } secret-string = "0.0.2" +base64 = "0.22.1" +ring = "0.17.14" [features] diff --git a/crates/contextforge-data-plane-lib/src/common.rs b/crates/contextforge-data-plane-lib/src/common.rs index b736b35e..de6421e2 100644 --- a/crates/contextforge-data-plane-lib/src/common.rs +++ b/crates/contextforge-data-plane-lib/src/common.rs @@ -16,6 +16,7 @@ use thiserror::Error; use typed_builder::TypedBuilder; use url::Url; +use crate::task_handle::TaskHandleKey; use crate::user_config_store::UserConfigStore; #[derive(Clone)] @@ -156,6 +157,11 @@ pub struct Config { #[arg(long, env = "CONTEXTFORGE_DATA_PLANE_TOKEN_SECRET")] pub token_verification_secret: Option>, + /// Shared AES-256 key used to protect stateless task handles. The value is + /// URL-safe base64 without padding and must decode to exactly 32 bytes. + #[arg(long, env = "CONTEXTFORGE_DATA_PLANE_TASK_HANDLE_KEY")] + pub task_handle_key: Option, + #[arg(long, env = "CONTEXTFORGE_DATA_PLANE_ENABLE_OPEN_TELEMETRY")] pub enable_open_telemetry: Option, diff --git a/crates/contextforge-data-plane-lib/src/lib.rs b/crates/contextforge-data-plane-lib/src/lib.rs index 4a7f9b32..d9d51151 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -14,6 +14,7 @@ mod common; mod const_values; mod gateway; mod layers; +pub mod task_handle; mod telemetry; mod transports; diff --git a/crates/contextforge-data-plane-lib/src/task_handle.rs b/crates/contextforge-data-plane-lib/src/task_handle.rs new file mode 100644 index 00000000..b65b5bab --- /dev/null +++ b/crates/contextforge-data-plane-lib/src/task_handle.rs @@ -0,0 +1,390 @@ +//! Stateless routing handles for the MCP Tasks extension. +//! +//! Handles are encrypted and authenticated because their payload contains an +//! upstream task identifier that may be a bearer token. The authenticated +//! subject and virtual-host scope prevents a handle from being replayed through +//! a different caller or route, while the backend lookup ensures that removed +//! backends fail closed. + +use std::{fmt, str::FromStr}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use contextforge_data_plane_apis::user_store::VirtualHost; +use ring::{ + aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey}, + rand::{SecureRandom, SystemRandom}, +}; +use rmcp::{ErrorData, model::ErrorCode}; +use secret_string::SecretString; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +const HANDLE_PREFIX: &str = "cfth1"; +const HANDLE_FAMILY_PREFIX: &str = "cfth"; +const KEY_LEN: usize = 32; +const NONCE_LEN: usize = 12; +const TAG_LEN: usize = 16; + +/// A validated AES-256 key for task-handle protection. +/// +/// The textual form is URL-safe base64 without padding and must decode to +/// exactly 32 bytes. Its [`Debug`] output is always redacted. +#[derive(Clone, PartialEq, Eq)] +pub struct TaskHandleKey(SecretString); + +impl TaskHandleKey { + fn bytes(&self) -> Result<[u8; KEY_LEN], TaskHandleKeyError> { + let bytes = URL_SAFE_NO_PAD.decode(self.0.value()).map_err(|_| TaskHandleKeyError)?; + bytes.try_into().map_err(|_| TaskHandleKeyError) + } +} + +impl FromStr for TaskHandleKey { + type Err = TaskHandleKeyError; + + fn from_str(value: &str) -> Result { + let key = Self(SecretString::new(value.to_owned())); + key.bytes()?; + Ok(key) + } +} + +impl fmt::Debug for TaskHandleKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("TaskHandleKey([REDACTED])") + } +} + +/// Returned when task-handle key material is not a URL-safe base64-encoded +/// 256-bit key. +#[derive(Debug, Error, PartialEq, Eq)] +#[error("task handle key must be URL-safe base64 without padding and decode to exactly 32 bytes")] +pub struct TaskHandleKeyError; + +/// Authenticated request scope to which a task handle is bound. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TaskHandleScope<'a> { + subject: &'a str, + virtual_host_id: &'a str, +} + +impl<'a> TaskHandleScope<'a> { + /// Creates a scope from the authenticated subject and path virtual-host ID. + pub fn new(subject: &'a str, virtual_host_id: &'a str) -> Self { + Self { subject, virtual_host_id } + } +} + +/// The route recovered from a valid task handle. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskHandleRoute { + backend_name: String, + upstream_task_id: String, +} + +impl TaskHandleRoute { + /// Backend map key in the caller's current virtual-host configuration. + pub fn backend_name(&self) -> &str { + &self.backend_name + } + + /// Original task identifier expected by the upstream backend. + pub fn upstream_task_id(&self) -> &str { + &self.upstream_task_id + } +} + +/// Errors produced while encoding or decoding task handles. +/// +/// Decode errors intentionally have the same display text so a caller cannot +/// distinguish a malformed handle from a valid handle outside its scope. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum TaskHandleError { + #[error("failed to create task handle")] + Encode, + #[error("invalid task handle")] + Invalid, + #[error("invalid task handle")] + UnsupportedVersion, + #[error("invalid task handle")] + WrongScope, + #[error("invalid task handle")] + UnavailableBackend, +} + +impl From for ErrorData { + fn from(error: TaskHandleError) -> Self { + match error { + TaskHandleError::Encode => ErrorData::new(ErrorCode::INTERNAL_ERROR, "failed to create task handle", None), + TaskHandleError::Invalid + | TaskHandleError::UnsupportedVersion + | TaskHandleError::WrongScope + | TaskHandleError::UnavailableBackend => ErrorData::new(ErrorCode::INVALID_PARAMS, "invalid task ID", None), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TaskHandlePayload { + subject: String, + virtual_host_id: String, + backend_name: String, + upstream_task_id: String, +} + +/// Encodes and decodes versioned task handles shared across dataplane replicas. +#[derive(Clone)] +pub struct TaskHandleCodec { + key: LessSafeKey, + random: SystemRandom, +} + +impl TaskHandleCodec { + /// Creates a codec from a validated shared key. + pub fn new(key: &TaskHandleKey) -> Result { + let key = UnboundKey::new(&AES_256_GCM, &key.bytes()?).map_err(|_| TaskHandleKeyError)?; + Ok(Self { key: LessSafeKey::new(key), random: SystemRandom::new() }) + } + + /// Creates an opaque handle for one upstream task. + pub fn encode( + &self, + scope: TaskHandleScope<'_>, + backend_name: &str, + upstream_task_id: &str, + ) -> Result { + let payload = TaskHandlePayload { + subject: scope.subject.to_owned(), + virtual_host_id: scope.virtual_host_id.to_owned(), + backend_name: backend_name.to_owned(), + upstream_task_id: upstream_task_id.to_owned(), + }; + let mut ciphertext = serde_json::to_vec(&payload).map_err(|_| TaskHandleError::Encode)?; + + let mut nonce_bytes = [0_u8; NONCE_LEN]; + self.random.fill(&mut nonce_bytes).map_err(|_| TaskHandleError::Encode)?; + let nonce = Nonce::assume_unique_for_key(nonce_bytes); + self.key + .seal_in_place_append_tag(nonce, Aad::from(HANDLE_PREFIX), &mut ciphertext) + .map_err(|_| TaskHandleError::Encode)?; + + let mut protected = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + protected.extend_from_slice(&nonce_bytes); + protected.extend_from_slice(&ciphertext); + Ok(format!("{HANDLE_PREFIX}.{}", URL_SAFE_NO_PAD.encode(protected))) + } + + /// Decodes a handle and verifies that it belongs to the authenticated + /// caller's current virtual host and references a currently configured + /// backend. + pub fn decode( + &self, + handle: &str, + expected_scope: TaskHandleScope<'_>, + virtual_host: &VirtualHost, + ) -> Result { + let (prefix, protected) = handle.split_once('.').ok_or(TaskHandleError::Invalid)?; + if prefix != HANDLE_PREFIX { + return if is_other_version(prefix) { + Err(TaskHandleError::UnsupportedVersion) + } else { + Err(TaskHandleError::Invalid) + }; + } + + let protected = URL_SAFE_NO_PAD.decode(protected).map_err(|_| TaskHandleError::Invalid)?; + if protected.len() < NONCE_LEN + TAG_LEN { + return Err(TaskHandleError::Invalid); + } + let (nonce_bytes, ciphertext) = protected.split_at(NONCE_LEN); + let nonce_bytes: [u8; NONCE_LEN] = nonce_bytes.try_into().map_err(|_| TaskHandleError::Invalid)?; + let nonce = Nonce::assume_unique_for_key(nonce_bytes); + let mut ciphertext = ciphertext.to_vec(); + let plaintext = self + .key + .open_in_place(nonce, Aad::from(HANDLE_PREFIX), &mut ciphertext) + .map_err(|_| TaskHandleError::Invalid)?; + let payload: TaskHandlePayload = serde_json::from_slice(plaintext).map_err(|_| TaskHandleError::Invalid)?; + + if payload.subject != expected_scope.subject || payload.virtual_host_id != expected_scope.virtual_host_id { + return Err(TaskHandleError::WrongScope); + } + if !virtual_host.backends.contains_key(&payload.backend_name) { + return Err(TaskHandleError::UnavailableBackend); + } + + Ok(TaskHandleRoute { backend_name: payload.backend_name, upstream_task_id: payload.upstream_task_id }) + } +} + +fn is_other_version(prefix: &str) -> bool { + prefix + .strip_prefix(HANDLE_FAMILY_PREFIX) + .is_some_and(|version| !version.is_empty() && version.bytes().all(|byte| byte.is_ascii_digit())) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use contextforge_data_plane_apis::user_store::BackendMCPGateway; + use url::Url; + + use super::*; + + const KEY: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; // pragma: allowlist secret + + fn codec() -> TaskHandleCodec { + TaskHandleCodec::new(&KEY.parse().expect("test key is valid")).expect("test key initializes AES-256-GCM") + } + + fn backend(name: &str) -> BackendMCPGateway { + BackendMCPGateway { + name: name.to_owned(), + url: Url::parse(&format!("https://{name}.example.com/mcp")).expect("test URL is valid"), + passthrough_headers: Vec::new(), + add_headers: HashMap::new(), + remove_headers: Vec::new(), + allowed_tool_names: Vec::new(), + tool_name_aliases: HashMap::new(), + allowed_resource_names: Vec::new(), + allowed_prompt_names: Vec::new(), + } + } + + fn virtual_host(names: &[&str]) -> VirtualHost { + VirtualHost { backends: names.iter().map(|name| ((*name).to_owned(), backend(name))).collect() } + } + + fn scope<'a>(subject: &'a str, virtual_host_id: &'a str) -> TaskHandleScope<'a> { + TaskHandleScope::new(subject, virtual_host_id) + } + + #[test] + fn arbitrary_upstream_task_ids_round_trip_without_loss() { + let codec = codec(); + let virtual_host = virtual_host(&["backend-a"]); + let task_ids = ["", "simple", "with/slashes?and=query", "nul\0byte", "emoji-🦀", "line\nbreak"]; + + for task_id in task_ids { + let handle = codec.encode(scope("caller", "host-a"), "backend-a", task_id).expect("handle encodes"); + let route = codec.decode(&handle, scope("caller", "host-a"), &virtual_host).expect("handle decodes"); + + assert_eq!(route.backend_name(), "backend-a"); + assert_eq!(route.upstream_task_id(), task_id); + } + } + + #[test] + fn identical_task_ids_from_different_backends_remain_isolated() { + let codec = codec(); + let virtual_host = virtual_host(&["backend-a", "backend-b"]); + let handle_a = codec.encode(scope("caller", "host-a"), "backend-a", "same-id").expect("handle A encodes"); + let handle_b = codec.encode(scope("caller", "host-a"), "backend-b", "same-id").expect("handle B encodes"); + + let route_a = codec.decode(&handle_a, scope("caller", "host-a"), &virtual_host).expect("handle A decodes"); + let route_b = codec.decode(&handle_b, scope("caller", "host-a"), &virtual_host).expect("handle B decodes"); + + assert_ne!(handle_a, handle_b); + assert_eq!(route_a.backend_name(), "backend-a"); + assert_eq!(route_b.backend_name(), "backend-b"); + } + + #[test] + fn handle_decodes_on_another_codec_with_the_same_key() { + let first_replica = codec(); + let second_replica = codec(); + let virtual_host = virtual_host(&["backend-a"]); + let handle = first_replica.encode(scope("caller", "host-a"), "backend-a", "task-42").expect("handle encodes"); + + let route = second_replica.decode(&handle, scope("caller", "host-a"), &virtual_host).expect("handle decodes"); + + assert_eq!(route.upstream_task_id(), "task-42"); + } + + #[test] + fn malformed_and_tampered_handles_fail_closed() { + let codec = codec(); + let virtual_host = virtual_host(&["backend-a"]); + let handle = codec.encode(scope("caller", "host-a"), "backend-a", "task-42").expect("handle encodes"); + let mut tampered = handle.into_bytes(); + let last = tampered.last_mut().expect("handle is non-empty"); + *last = if *last == b'A' { b'B' } else { b'A' }; + let tampered = String::from_utf8(tampered).expect("tampered handle remains UTF-8"); + + assert_eq!( + codec.decode("not-a-handle", scope("caller", "host-a"), &virtual_host), + Err(TaskHandleError::Invalid) + ); + assert_eq!(codec.decode("cfth1.AA", scope("caller", "host-a"), &virtual_host), Err(TaskHandleError::Invalid)); + assert_eq!(codec.decode(&tampered, scope("caller", "host-a"), &virtual_host), Err(TaskHandleError::Invalid)); + } + + #[test] + fn unsupported_handle_versions_are_rejected() { + let codec = codec(); + let virtual_host = virtual_host(&["backend-a"]); + + assert_eq!( + codec.decode("cfth2.AA", scope("caller", "host-a"), &virtual_host), + Err(TaskHandleError::UnsupportedVersion) + ); + assert_eq!(codec.decode("cfthx.AA", scope("caller", "host-a"), &virtual_host), Err(TaskHandleError::Invalid)); + } + + #[test] + fn caller_and_virtual_host_scope_are_enforced() { + let codec = codec(); + let virtual_host = virtual_host(&["backend-a"]); + let handle = codec.encode(scope("caller-a", "host-a"), "backend-a", "task-42").expect("handle encodes"); + + assert_eq!(codec.decode(&handle, scope("caller-b", "host-a"), &virtual_host), Err(TaskHandleError::WrongScope)); + assert_eq!(codec.decode(&handle, scope("caller-a", "host-b"), &virtual_host), Err(TaskHandleError::WrongScope)); + } + + #[test] + fn removed_backends_are_rejected_without_exposing_the_backend_name() { + let codec = codec(); + let original_virtual_host = virtual_host(&["backend-a"]); + let current_virtual_host = virtual_host(&["backend-b"]); + let handle = codec.encode(scope("caller", "host-a"), "backend-a", "task-42").expect("handle encodes"); + + assert!(codec.decode(&handle, scope("caller", "host-a"), &original_virtual_host).is_ok()); + let error = + codec.decode(&handle, scope("caller", "host-a"), ¤t_virtual_host).expect_err("backend was removed"); + + assert_eq!(error, TaskHandleError::UnavailableBackend); + assert_eq!(error.to_string(), "invalid task handle"); + assert!(!error.to_string().contains("backend-a")); + } + + #[test] + fn invalid_keys_are_rejected_and_debug_output_is_redacted() { + assert_eq!("short".parse::(), Err(TaskHandleKeyError)); + let key: TaskHandleKey = KEY.parse().expect("test key is valid"); + + assert_eq!(format!("{key:?}"), "TaskHandleKey([REDACTED])"); + assert!(!format!("{key:?}").contains(KEY)); + } + + #[test] + fn decode_errors_map_to_indistinguishable_invalid_params_errors() { + for error in [ + TaskHandleError::Invalid, + TaskHandleError::UnsupportedVersion, + TaskHandleError::WrongScope, + TaskHandleError::UnavailableBackend, + ] { + let protocol_error = ErrorData::from(error); + + assert_eq!(protocol_error.code, ErrorCode::INVALID_PARAMS); + assert_eq!(protocol_error.message, "invalid task ID"); + assert_eq!(protocol_error.data, None); + } + + let protocol_error = ErrorData::from(TaskHandleError::Encode); + assert_eq!(protocol_error.code, ErrorCode::INTERNAL_ERROR); + assert_eq!(protocol_error.message, "failed to create task handle"); + } +} From 514168b35f4ea568ec2a648aa2f785e708abafc1 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 18 Aug 2026 09:02:43 +0100 Subject: [PATCH 2/3] Harden stateless task-handle scope Signed-off-by: lucarlig --- Cargo.lock | 143 +++++++- _context/wiki/mcp-capability-allocation.md | 19 ++ _context/wiki/routing.md | 14 +- _context/wiki/security.md | 5 +- crates/contextforge-data-plane-lib/Cargo.toml | 3 +- .../contextforge-data-plane-lib/src/common.rs | 4 +- .../src/task_handle.rs | 304 +++++++++++------- 7 files changed, 370 insertions(+), 122 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18a51d28..573345f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,42 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm-siv" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f437e5b075722bda3f54039e95d60d1c142f140e6c45b718e4f9fca3e5a1514" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "polyval", + "subtle", + "zeroize", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -364,6 +400,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -455,6 +500,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "clap" version = "4.6.6" @@ -504,6 +560,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -611,6 +673,7 @@ dependencies = [ name = "contextforge-data-plane-lib" version = "0.1.0" dependencies = [ + "aes-gcm-siv", "async-trait", "axum", "axum-otel-metrics", @@ -623,6 +686,7 @@ dependencies = [ "cpex", "cpex-secrets-detection", "futures", + "getrandom 0.4.3", "http", "hyper", "hyper-util", @@ -633,7 +697,6 @@ dependencies = [ "opentelemetry_sdk", "redis", "reqwest", - "ring", "rmcp", "rmp-serde", "rustls", @@ -801,6 +864,12 @@ dependencies = [ "cpex", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -853,6 +922,26 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + [[package]] name = "ctrlc" version = "3.5.2" @@ -864,6 +953,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.24.0" @@ -916,8 +1014,8 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", ] [[package]] @@ -1397,6 +1495,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -1615,6 +1722,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.1" @@ -2127,6 +2243,17 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.14.0" @@ -3672,6 +3799,16 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/_context/wiki/mcp-capability-allocation.md b/_context/wiki/mcp-capability-allocation.md index 2cfc0610..be40dbf4 100644 --- a/_context/wiki/mcp-capability-allocation.md +++ b/_context/wiki/mcp-capability-allocation.md @@ -138,6 +138,25 @@ authorization context. dataplane, and integration tests must define together. The current coarse `sub`-only implementation is not the Phase 3 target. +### Stateless Task Handles + +Modern Tasks lifecycle calls are targeted operations under the same +authorization invariants. The dataplane exposes an encrypted, stateless handle +rather than an upstream task ID or process-local mapping. The handle binds the +upstream ID and route to a trusted authorization-context ID, virtual server, +configuration revision, immutable backend ID, and backend generation. + +Every `tasks/get`, `tasks/update`, and `tasks/cancel` request independently +derives its authorization context from verified claims and the validated route, +loads the current effective configuration, enforces method scope and compiled +policy, and accepts the handle only when its backend ID still resolves to the +same generation. A mismatch returns the same invalid-task error as malformed +input and makes no upstream call. Dataplane replicas share the handle key so +decoding does not require Redis task state or session affinity. + +The codec is a prerequisite only; task creation and lifecycle proxy handlers +remain separate implementation work and are not current routing behavior. + ## MCP Work Allocation | Work | Target owner and behavior | diff --git a/_context/wiki/routing.md b/_context/wiki/routing.md index 4a8654d0..36386b53 100644 --- a/_context/wiki/routing.md +++ b/_context/wiki/routing.md @@ -52,11 +52,17 @@ The gateway wraps per-backend cursors inside its own opaque token (JSON, treated **Known limitation:** if backend set changes between pages, removed backend's cursor is silently dropped. -## Task Handles +## Task-Handle Codec (not wired) -- Never expose an upstream task ID directly. -- Encode it as `cfth1.` with AES-256-GCM and a random nonce. -- Bind the payload to JWT `sub`, virtual host, and backend; reject mismatches and removed backends as `invalid task ID`. +The library contains the codec prerequisite for modern Tasks, but no current +handler emits task handles or proxies `tasks/get`, `tasks/update`, or +`tasks/cancel` yet. + +- Never expose or log an upstream task ID directly; decoded-route debug output redacts it. +- Encode it as `cfth1.` with misuse-resistant AES-256-GCM-SIV and a random nonce. +- Bind the payload to the trusted authorization-context ID, virtual host, configuration revision, backend ID, and backend generation. +- On decode, independently derive the current authorization scope and accept the backend only when the effective configuration resolves the same ID and generation. +- Return every scope, revision, route, malformed-input, and version mismatch as `invalid task ID` without an upstream call. - Handles are stateless. Replicas must share the key; key rotation invalidates outstanding handles. ## Session State (local process) diff --git a/_context/wiki/security.md b/_context/wiki/security.md index aac94484..b98747e5 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -84,6 +84,7 @@ These routes are registered **outside the authentication middleware** — unauth ## Secrets Handling - The HMAC secret is held as a `SecretString`; key and certificate material is read from disk paths at startup. -- Task handles are authenticated, encrypted, and scoped to JWT `sub` + virtual host + backend. They do not replace JWT validation. +- The not-yet-wired task-handle codec encrypts upstream task IDs and binds them to a trusted authorization-context ID, virtual host, configuration revision, backend ID, and backend generation. +- Task handles do not replace per-request JWT validation, effective-configuration lookup, scope/RBAC checks, or current backend-generation validation. - Never log: tokens, authorization headers, secrets, Redis key/value bytes, full `UserConfig` documents, or backend credentials. -- Treat task handles as opaque; do not log them. +- Treat task handles and decoded upstream task IDs as opaque secrets; do not log them. Decoded-route debug output must remain redacted. diff --git a/crates/contextforge-data-plane-lib/Cargo.toml b/crates/contextforge-data-plane-lib/Cargo.toml index 72400621..fad61046 100644 --- a/crates/contextforge-data-plane-lib/Cargo.toml +++ b/crates/contextforge-data-plane-lib/Cargo.toml @@ -47,7 +47,8 @@ typed-builder = "0.23.2" url = { workspace = true, features = ["serde"] } secret-string = "0.0.2" base64 = "0.22.1" -ring = "0.17.14" +aes-gcm-siv = { version = "0.12.0", features = ["zeroize"] } +getrandom = "0.4.3" [features] diff --git a/crates/contextforge-data-plane-lib/src/common.rs b/crates/contextforge-data-plane-lib/src/common.rs index de6421e2..c5f891ea 100644 --- a/crates/contextforge-data-plane-lib/src/common.rs +++ b/crates/contextforge-data-plane-lib/src/common.rs @@ -157,8 +157,8 @@ pub struct Config { #[arg(long, env = "CONTEXTFORGE_DATA_PLANE_TOKEN_SECRET")] pub token_verification_secret: Option>, - /// Shared AES-256 key used to protect stateless task handles. The value is - /// URL-safe base64 without padding and must decode to exactly 32 bytes. + /// Shared AES-256-GCM-SIV key used to protect stateless task handles. The + /// value is URL-safe base64 without padding and must decode to 32 bytes. #[arg(long, env = "CONTEXTFORGE_DATA_PLANE_TASK_HANDLE_KEY")] pub task_handle_key: Option, diff --git a/crates/contextforge-data-plane-lib/src/task_handle.rs b/crates/contextforge-data-plane-lib/src/task_handle.rs index b65b5bab..6fb2cadd 100644 --- a/crates/contextforge-data-plane-lib/src/task_handle.rs +++ b/crates/contextforge-data-plane-lib/src/task_handle.rs @@ -1,19 +1,18 @@ //! Stateless routing handles for the MCP Tasks extension. //! //! Handles are encrypted and authenticated because their payload contains an -//! upstream task identifier that may be a bearer token. The authenticated -//! subject and virtual-host scope prevents a handle from being replayed through -//! a different caller or route, while the backend lookup ensures that removed -//! backends fail closed. +//! upstream task identifier that may be a bearer token. The trusted +//! authorization context, virtual host, configuration revision, and backend +//! generation prevent replay through a different caller, policy snapshot, or +//! upstream route. use std::{fmt, str::FromStr}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use contextforge_data_plane_apis::user_store::VirtualHost; -use ring::{ - aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey}, - rand::{SecureRandom, SystemRandom}, +use aes_gcm_siv::{ + Aes256GcmSiv, Nonce, + aead::{Aead, KeyInit, Payload as AeadPayload}, }; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use rmcp::{ErrorData, model::ErrorCode}; use secret_string::SecretString; use serde::{Deserialize, Serialize}; @@ -25,7 +24,7 @@ const KEY_LEN: usize = 32; const NONCE_LEN: usize = 12; const TAG_LEN: usize = 16; -/// A validated AES-256 key for task-handle protection. +/// A validated AES-256-GCM-SIV key for task-handle protection. /// /// The textual form is URL-safe base64 without padding and must decode to /// exactly 32 bytes. Its [`Debug`] output is always redacted. @@ -62,30 +61,63 @@ impl fmt::Debug for TaskHandleKey { pub struct TaskHandleKeyError; /// Authenticated request scope to which a task handle is bound. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// +/// The authorization-context ID and configuration revision must come from the +/// verified JWT and the validated effective-configuration snapshot. Client +/// metadata and MCP params are not trusted sources for either value. +#[derive(Clone, Copy, PartialEq, Eq)] pub struct TaskHandleScope<'a> { - subject: &'a str, + authorization_context_id: &'a str, virtual_host_id: &'a str, + configuration_revision: &'a str, } impl<'a> TaskHandleScope<'a> { - /// Creates a scope from the authenticated subject and path virtual-host ID. - pub fn new(subject: &'a str, virtual_host_id: &'a str) -> Self { - Self { subject, virtual_host_id } + /// Creates a scope from trusted authorization and routing context. + pub fn new(authorization_context_id: &'a str, virtual_host_id: &'a str, configuration_revision: &'a str) -> Self { + Self { authorization_context_id, virtual_host_id, configuration_revision } + } +} + +/// Stable backend identity stored in a task handle. +/// +/// `generation` must change when the routing key is reassigned to a different +/// upstream or when routing material changes in a way that invalidates +/// outstanding upstream task IDs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TaskHandleBackend<'a> { + id: &'a str, + generation: &'a str, +} + +impl<'a> TaskHandleBackend<'a> { + /// Creates backend identity from trusted effective configuration. + pub fn new(id: &'a str, generation: &'a str) -> Self { + Self { id, generation } + } + + /// Stable backend identifier used for routing. + pub fn id(self) -> &'a str { + self.id + } + + /// Generation of the backend routing material. + pub fn generation(self) -> &'a str { + self.generation } } /// The route recovered from a valid task handle. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct TaskHandleRoute { - backend_name: String, + backend_id: String, upstream_task_id: String, } impl TaskHandleRoute { - /// Backend map key in the caller's current virtual-host configuration. - pub fn backend_name(&self) -> &str { - &self.backend_name + /// Stable backend ID in the caller's current effective configuration. + pub fn backend_id(&self) -> &str { + &self.backend_id } /// Original task identifier expected by the upstream backend. @@ -94,6 +126,15 @@ impl TaskHandleRoute { } } +impl fmt::Debug for TaskHandleRoute { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TaskHandleRoute") + .field("backend_id", &self.backend_id) + .field("upstream_task_id", &"[REDACTED]") + .finish() + } +} + /// Errors produced while encoding or decoding task handles. /// /// Decode errors intentionally have the same display text so a caller cannot @@ -124,49 +165,53 @@ impl From for ErrorData { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct TaskHandlePayload { - subject: String, + authorization_context_id: String, virtual_host_id: String, - backend_name: String, + configuration_revision: String, + backend_id: String, + backend_generation: String, upstream_task_id: String, } /// Encodes and decodes versioned task handles shared across dataplane replicas. #[derive(Clone)] pub struct TaskHandleCodec { - key: LessSafeKey, - random: SystemRandom, + cipher: Aes256GcmSiv, } impl TaskHandleCodec { /// Creates a codec from a validated shared key. pub fn new(key: &TaskHandleKey) -> Result { - let key = UnboundKey::new(&AES_256_GCM, &key.bytes()?).map_err(|_| TaskHandleKeyError)?; - Ok(Self { key: LessSafeKey::new(key), random: SystemRandom::new() }) + let cipher = Aes256GcmSiv::new_from_slice(&key.bytes()?).map_err(|_| TaskHandleKeyError)?; + Ok(Self { cipher }) } /// Creates an opaque handle for one upstream task. pub fn encode( &self, scope: TaskHandleScope<'_>, - backend_name: &str, + backend: TaskHandleBackend<'_>, upstream_task_id: &str, ) -> Result { let payload = TaskHandlePayload { - subject: scope.subject.to_owned(), + authorization_context_id: scope.authorization_context_id.to_owned(), virtual_host_id: scope.virtual_host_id.to_owned(), - backend_name: backend_name.to_owned(), + configuration_revision: scope.configuration_revision.to_owned(), + backend_id: backend.id.to_owned(), + backend_generation: backend.generation.to_owned(), upstream_task_id: upstream_task_id.to_owned(), }; - let mut ciphertext = serde_json::to_vec(&payload).map_err(|_| TaskHandleError::Encode)?; + let plaintext = serde_json::to_vec(&payload).map_err(|_| TaskHandleError::Encode)?; let mut nonce_bytes = [0_u8; NONCE_LEN]; - self.random.fill(&mut nonce_bytes).map_err(|_| TaskHandleError::Encode)?; - let nonce = Nonce::assume_unique_for_key(nonce_bytes); - self.key - .seal_in_place_append_tag(nonce, Aad::from(HANDLE_PREFIX), &mut ciphertext) + getrandom::fill(&mut nonce_bytes).map_err(|_| TaskHandleError::Encode)?; + let nonce = Nonce::from(nonce_bytes); + let ciphertext = self + .cipher + .encrypt(&nonce, AeadPayload { msg: &plaintext, aad: HANDLE_PREFIX.as_bytes() }) .map_err(|_| TaskHandleError::Encode)?; let mut protected = Vec::with_capacity(NONCE_LEN + ciphertext.len()); @@ -176,14 +221,17 @@ impl TaskHandleCodec { } /// Decodes a handle and verifies that it belongs to the authenticated - /// caller's current virtual host and references a currently configured - /// backend. - pub fn decode( + /// authorization context and references the same backend generation in + /// current effective configuration. + pub fn decode( &self, handle: &str, expected_scope: TaskHandleScope<'_>, - virtual_host: &VirtualHost, - ) -> Result { + backend_is_current: F, + ) -> Result + where + F: FnOnce(TaskHandleBackend<'_>) -> bool, + { let (prefix, protected) = handle.split_once('.').ok_or(TaskHandleError::Invalid)?; if prefix != HANDLE_PREFIX { return if is_other_version(prefix) { @@ -199,22 +247,24 @@ impl TaskHandleCodec { } let (nonce_bytes, ciphertext) = protected.split_at(NONCE_LEN); let nonce_bytes: [u8; NONCE_LEN] = nonce_bytes.try_into().map_err(|_| TaskHandleError::Invalid)?; - let nonce = Nonce::assume_unique_for_key(nonce_bytes); - let mut ciphertext = ciphertext.to_vec(); + let nonce = Nonce::from(nonce_bytes); let plaintext = self - .key - .open_in_place(nonce, Aad::from(HANDLE_PREFIX), &mut ciphertext) + .cipher + .decrypt(&nonce, AeadPayload { msg: ciphertext, aad: HANDLE_PREFIX.as_bytes() }) .map_err(|_| TaskHandleError::Invalid)?; - let payload: TaskHandlePayload = serde_json::from_slice(plaintext).map_err(|_| TaskHandleError::Invalid)?; + let payload: TaskHandlePayload = serde_json::from_slice(&plaintext).map_err(|_| TaskHandleError::Invalid)?; - if payload.subject != expected_scope.subject || payload.virtual_host_id != expected_scope.virtual_host_id { + if payload.authorization_context_id != expected_scope.authorization_context_id + || payload.virtual_host_id != expected_scope.virtual_host_id + || payload.configuration_revision != expected_scope.configuration_revision + { return Err(TaskHandleError::WrongScope); } - if !virtual_host.backends.contains_key(&payload.backend_name) { + if !backend_is_current(TaskHandleBackend::new(&payload.backend_id, &payload.backend_generation)) { return Err(TaskHandleError::UnavailableBackend); } - Ok(TaskHandleRoute { backend_name: payload.backend_name, upstream_task_id: payload.upstream_task_id }) + Ok(TaskHandleRoute { backend_id: payload.backend_id, upstream_task_id: payload.upstream_task_id }) } } @@ -226,52 +276,61 @@ fn is_other_version(prefix: &str) -> bool { #[cfg(test)] mod tests { - use std::collections::HashMap; - - use contextforge_data_plane_apis::user_store::BackendMCPGateway; - use url::Url; - use super::*; const KEY: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; // pragma: allowlist secret + const AUTHORIZATION_CONTEXT: &str = "tenant-a:principal-a:team-a:scope-set-a"; + const VIRTUAL_HOST_ID: &str = "host-a"; + const CONFIGURATION_REVISION: &str = "revision-7"; + const BACKEND_ID: &str = "backend-a"; + const BACKEND_GENERATION: &str = "generation-3"; fn codec() -> TaskHandleCodec { - TaskHandleCodec::new(&KEY.parse().expect("test key is valid")).expect("test key initializes AES-256-GCM") + TaskHandleCodec::new(&KEY.parse().expect("test key is valid")).expect("test key initializes AES-256-GCM-SIV") } - fn backend(name: &str) -> BackendMCPGateway { - BackendMCPGateway { - name: name.to_owned(), - url: Url::parse(&format!("https://{name}.example.com/mcp")).expect("test URL is valid"), - passthrough_headers: Vec::new(), - add_headers: HashMap::new(), - remove_headers: Vec::new(), - allowed_tool_names: Vec::new(), - tool_name_aliases: HashMap::new(), - allowed_resource_names: Vec::new(), - allowed_prompt_names: Vec::new(), - } + fn scope<'a>( + authorization_context_id: &'a str, + virtual_host_id: &'a str, + configuration_revision: &'a str, + ) -> TaskHandleScope<'a> { + TaskHandleScope::new(authorization_context_id, virtual_host_id, configuration_revision) } - fn virtual_host(names: &[&str]) -> VirtualHost { - VirtualHost { backends: names.iter().map(|name| ((*name).to_owned(), backend(name))).collect() } + fn current_scope() -> TaskHandleScope<'static> { + scope(AUTHORIZATION_CONTEXT, VIRTUAL_HOST_ID, CONFIGURATION_REVISION) } - fn scope<'a>(subject: &'a str, virtual_host_id: &'a str) -> TaskHandleScope<'a> { - TaskHandleScope::new(subject, virtual_host_id) + fn backend<'a>(id: &'a str, generation: &'a str) -> TaskHandleBackend<'a> { + TaskHandleBackend::new(id, generation) + } + + fn current_backend() -> TaskHandleBackend<'static> { + backend(BACKEND_ID, BACKEND_GENERATION) + } + + fn decode_current( + codec: &TaskHandleCodec, + handle: &str, + expected_scope: TaskHandleScope<'_>, + expected_backend: TaskHandleBackend<'_>, + ) -> Result { + codec.decode(handle, expected_scope, |decoded_backend| { + decoded_backend.id() == expected_backend.id() + && decoded_backend.generation() == expected_backend.generation() + }) } #[test] fn arbitrary_upstream_task_ids_round_trip_without_loss() { let codec = codec(); - let virtual_host = virtual_host(&["backend-a"]); let task_ids = ["", "simple", "with/slashes?and=query", "nul\0byte", "emoji-🦀", "line\nbreak"]; for task_id in task_ids { - let handle = codec.encode(scope("caller", "host-a"), "backend-a", task_id).expect("handle encodes"); - let route = codec.decode(&handle, scope("caller", "host-a"), &virtual_host).expect("handle decodes"); + let handle = codec.encode(current_scope(), current_backend(), task_id).expect("handle encodes"); + let route = decode_current(&codec, &handle, current_scope(), current_backend()).expect("handle decodes"); - assert_eq!(route.backend_name(), "backend-a"); + assert_eq!(route.backend_id(), BACKEND_ID); assert_eq!(route.upstream_task_id(), task_id); } } @@ -279,26 +338,27 @@ mod tests { #[test] fn identical_task_ids_from_different_backends_remain_isolated() { let codec = codec(); - let virtual_host = virtual_host(&["backend-a", "backend-b"]); - let handle_a = codec.encode(scope("caller", "host-a"), "backend-a", "same-id").expect("handle A encodes"); - let handle_b = codec.encode(scope("caller", "host-a"), "backend-b", "same-id").expect("handle B encodes"); + let backend_a = backend("backend-a", "generation-a"); + let backend_b = backend("backend-b", "generation-b"); + let handle_a = codec.encode(current_scope(), backend_a, "same-id").expect("handle A encodes"); + let handle_b = codec.encode(current_scope(), backend_b, "same-id").expect("handle B encodes"); - let route_a = codec.decode(&handle_a, scope("caller", "host-a"), &virtual_host).expect("handle A decodes"); - let route_b = codec.decode(&handle_b, scope("caller", "host-a"), &virtual_host).expect("handle B decodes"); + let route_a = decode_current(&codec, &handle_a, current_scope(), backend_a).expect("handle A decodes"); + let route_b = decode_current(&codec, &handle_b, current_scope(), backend_b).expect("handle B decodes"); assert_ne!(handle_a, handle_b); - assert_eq!(route_a.backend_name(), "backend-a"); - assert_eq!(route_b.backend_name(), "backend-b"); + assert_eq!(route_a.backend_id(), "backend-a"); + assert_eq!(route_b.backend_id(), "backend-b"); } #[test] fn handle_decodes_on_another_codec_with_the_same_key() { let first_replica = codec(); let second_replica = codec(); - let virtual_host = virtual_host(&["backend-a"]); - let handle = first_replica.encode(scope("caller", "host-a"), "backend-a", "task-42").expect("handle encodes"); + let handle = first_replica.encode(current_scope(), current_backend(), "task-42").expect("handle encodes"); - let route = second_replica.decode(&handle, scope("caller", "host-a"), &virtual_host).expect("handle decodes"); + let route = + decode_current(&second_replica, &handle, current_scope(), current_backend()).expect("handle decodes"); assert_eq!(route.upstream_task_id(), "task-42"); } @@ -306,66 +366,90 @@ mod tests { #[test] fn malformed_and_tampered_handles_fail_closed() { let codec = codec(); - let virtual_host = virtual_host(&["backend-a"]); - let handle = codec.encode(scope("caller", "host-a"), "backend-a", "task-42").expect("handle encodes"); + let handle = codec.encode(current_scope(), current_backend(), "task-42").expect("handle encodes"); let mut tampered = handle.into_bytes(); let last = tampered.last_mut().expect("handle is non-empty"); *last = if *last == b'A' { b'B' } else { b'A' }; let tampered = String::from_utf8(tampered).expect("tampered handle remains UTF-8"); assert_eq!( - codec.decode("not-a-handle", scope("caller", "host-a"), &virtual_host), + decode_current(&codec, "not-a-handle", current_scope(), current_backend()), + Err(TaskHandleError::Invalid) + ); + assert_eq!( + decode_current(&codec, "cfth1.AA", current_scope(), current_backend()), + Err(TaskHandleError::Invalid) + ); + assert_eq!( + decode_current(&codec, &tampered, current_scope(), current_backend()), Err(TaskHandleError::Invalid) ); - assert_eq!(codec.decode("cfth1.AA", scope("caller", "host-a"), &virtual_host), Err(TaskHandleError::Invalid)); - assert_eq!(codec.decode(&tampered, scope("caller", "host-a"), &virtual_host), Err(TaskHandleError::Invalid)); } #[test] fn unsupported_handle_versions_are_rejected() { let codec = codec(); - let virtual_host = virtual_host(&["backend-a"]); assert_eq!( - codec.decode("cfth2.AA", scope("caller", "host-a"), &virtual_host), + decode_current(&codec, "cfth2.AA", current_scope(), current_backend()), Err(TaskHandleError::UnsupportedVersion) ); - assert_eq!(codec.decode("cfthx.AA", scope("caller", "host-a"), &virtual_host), Err(TaskHandleError::Invalid)); + assert_eq!( + decode_current(&codec, "cfthx.AA", current_scope(), current_backend()), + Err(TaskHandleError::Invalid) + ); } #[test] - fn caller_and_virtual_host_scope_are_enforced() { + fn authorization_virtual_host_and_revision_scope_are_enforced() { let codec = codec(); - let virtual_host = virtual_host(&["backend-a"]); - let handle = codec.encode(scope("caller-a", "host-a"), "backend-a", "task-42").expect("handle encodes"); + let handle = codec.encode(current_scope(), current_backend(), "task-42").expect("handle encodes"); - assert_eq!(codec.decode(&handle, scope("caller-b", "host-a"), &virtual_host), Err(TaskHandleError::WrongScope)); - assert_eq!(codec.decode(&handle, scope("caller-a", "host-b"), &virtual_host), Err(TaskHandleError::WrongScope)); + for wrong_scope in [ + scope("tenant-b:principal-a:team-a:scope-set-a", VIRTUAL_HOST_ID, CONFIGURATION_REVISION), + scope(AUTHORIZATION_CONTEXT, "host-b", CONFIGURATION_REVISION), + scope(AUTHORIZATION_CONTEXT, VIRTUAL_HOST_ID, "revision-8"), + ] { + assert_eq!( + decode_current(&codec, &handle, wrong_scope, current_backend()), + Err(TaskHandleError::WrongScope) + ); + } } #[test] - fn removed_backends_are_rejected_without_exposing_the_backend_name() { + fn removed_or_reassigned_backends_are_rejected_without_exposing_identity() { let codec = codec(); - let original_virtual_host = virtual_host(&["backend-a"]); - let current_virtual_host = virtual_host(&["backend-b"]); - let handle = codec.encode(scope("caller", "host-a"), "backend-a", "task-42").expect("handle encodes"); - - assert!(codec.decode(&handle, scope("caller", "host-a"), &original_virtual_host).is_ok()); - let error = - codec.decode(&handle, scope("caller", "host-a"), ¤t_virtual_host).expect_err("backend was removed"); - - assert_eq!(error, TaskHandleError::UnavailableBackend); - assert_eq!(error.to_string(), "invalid task handle"); - assert!(!error.to_string().contains("backend-a")); + let handle = codec.encode(current_scope(), current_backend(), "task-42").expect("handle encodes"); + + assert!(decode_current(&codec, &handle, current_scope(), current_backend()).is_ok()); + let removed = codec.decode(&handle, current_scope(), |_| false).expect_err("removed backend is rejected"); + let reassigned = decode_current(&codec, &handle, current_scope(), backend(BACKEND_ID, "generation-4")) + .expect_err("reassigned backend is rejected"); + + for error in [removed, reassigned] { + assert_eq!(error, TaskHandleError::UnavailableBackend); + assert_eq!(error.to_string(), "invalid task handle"); + assert!(!error.to_string().contains(BACKEND_ID)); + assert!(!error.to_string().contains(BACKEND_GENERATION)); + } } #[test] - fn invalid_keys_are_rejected_and_debug_output_is_redacted() { + fn invalid_keys_and_sensitive_debug_output_are_redacted() { assert_eq!("short".parse::(), Err(TaskHandleKeyError)); let key: TaskHandleKey = KEY.parse().expect("test key is valid"); + let codec = TaskHandleCodec::new(&key).expect("test key initializes codec"); + let handle = codec.encode(current_scope(), current_backend(), "bearer-task-id").expect("handle encodes"); + let route = decode_current(&codec, &handle, current_scope(), current_backend()).expect("handle decodes"); assert_eq!(format!("{key:?}"), "TaskHandleKey([REDACTED])"); assert!(!format!("{key:?}").contains(KEY)); + assert_eq!( + format!("{route:?}"), + "TaskHandleRoute { backend_id: \"backend-a\", upstream_task_id: \"[REDACTED]\" }" + ); + assert!(!format!("{route:?}").contains("bearer-task-id")); } #[test] From e8cc86c7da2b7a325132f10a4c3333501fb03730 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 18 Aug 2026 09:45:28 +0100 Subject: [PATCH 3/3] Harden task handle API Signed-off-by: lucarlig --- .secrets.baseline | 8 +- Cargo.lock | 2 +- crates/contextforge-data-plane-lib/Cargo.toml | 2 +- .../contextforge-data-plane-lib/src/common.rs | 6 - .../src/gateway/mcp_call_validator.rs | 12 +- .../src/layers/virtual_host_config.rs | 4 +- .../src/layers/virtual_host_id.rs | 17 +- crates/contextforge-data-plane-lib/src/lib.rs | 1 + .../src/task_handle.rs | 243 +++++++++++------- 9 files changed, 181 insertions(+), 114 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 4c30b07e..7cf529b9 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "Cargo\\.lock$|\\.lock$|target/|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-08-18T08:49:35Z", + "generated_at": "2026-08-11T12:22:16Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -142,7 +142,7 @@ "hashed_secret": "4a4645604f0b9e29503be96a87f6f47a6e4a7890", "is_secret": false, "is_verified": false, - "line_number": 155, + "line_number": 154, "type": "Secret Keyword", "verified_result": null }, @@ -150,7 +150,7 @@ "hashed_secret": "427f5e1b530d4a544883308d876a11d724060c86", "is_secret": false, "is_verified": false, - "line_number": 158, + "line_number": 157, "type": "Secret Keyword", "verified_result": null }, @@ -158,7 +158,7 @@ "hashed_secret": "bfc6000db1195a9522813fc405c666dd4ce669ad", "is_secret": false, "is_verified": false, - "line_number": 256, + "line_number": 250, "type": "Secret Keyword", "verified_result": null } diff --git a/Cargo.lock b/Cargo.lock index 573345f0..e64ebc9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -686,7 +686,6 @@ dependencies = [ "cpex", "cpex-secrets-detection", "futures", - "getrandom 0.4.3", "http", "hyper", "hyper-util", @@ -716,6 +715,7 @@ dependencies = [ "typed-builder", "url", "uuid", + "zeroize", ] [[package]] diff --git a/crates/contextforge-data-plane-lib/Cargo.toml b/crates/contextforge-data-plane-lib/Cargo.toml index fad61046..aef46470 100644 --- a/crates/contextforge-data-plane-lib/Cargo.toml +++ b/crates/contextforge-data-plane-lib/Cargo.toml @@ -48,7 +48,7 @@ url = { workspace = true, features = ["serde"] } secret-string = "0.0.2" base64 = "0.22.1" aes-gcm-siv = { version = "0.12.0", features = ["zeroize"] } -getrandom = "0.4.3" +zeroize = "1.9.0" [features] diff --git a/crates/contextforge-data-plane-lib/src/common.rs b/crates/contextforge-data-plane-lib/src/common.rs index c5f891ea..b736b35e 100644 --- a/crates/contextforge-data-plane-lib/src/common.rs +++ b/crates/contextforge-data-plane-lib/src/common.rs @@ -16,7 +16,6 @@ use thiserror::Error; use typed_builder::TypedBuilder; use url::Url; -use crate::task_handle::TaskHandleKey; use crate::user_config_store::UserConfigStore; #[derive(Clone)] @@ -157,11 +156,6 @@ pub struct Config { #[arg(long, env = "CONTEXTFORGE_DATA_PLANE_TOKEN_SECRET")] pub token_verification_secret: Option>, - /// Shared AES-256-GCM-SIV key used to protect stateless task handles. The - /// value is URL-safe base64 without padding and must decode to 32 bytes. - #[arg(long, env = "CONTEXTFORGE_DATA_PLANE_TASK_HANDLE_KEY")] - pub task_handle_key: Option, - #[arg(long, env = "CONTEXTFORGE_DATA_PLANE_ENABLE_OPEN_TELEMETRY")] pub enable_open_telemetry: Option, diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_call_validator.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_call_validator.rs index 026de314..f1c33d64 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_call_validator.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_call_validator.rs @@ -29,7 +29,7 @@ impl<'a> AuthorizedCallValidator<'a> { let virtual_hosts = maybe_user_config.map_or(0, |user_config| user_config.virtual_hosts.len()); let has_session_id = maybe_session_id.is_some(); let has_claims = maybe_claims.is_some(); - let virtual_host_id = maybe_virtual_host_id.map_or("", |id| id.value().as_str()); + let virtual_host_id = maybe_virtual_host_id.map_or("", VirtualHostId::as_str); debug!( "AuthorizedCallValidator::validate - mcp call validation call_name = {call_name} has_user_config = {has_user_config} virtual_hosts = {virtual_hosts} has_session_id = {has_session_id} has_claims = {has_claims} virtual_host_id = {virtual_host_id}" ); @@ -58,9 +58,9 @@ impl<'a> AuthorizedCallValidator<'a> { }); }; - let Some(virtual_host) = user_config.virtual_hosts.get(virtual_host_id.value()) else { + let Some(virtual_host) = user_config.virtual_hosts.get(virtual_host_id.as_str()) else { let call_name = self.call_name; - let virtual_host_id = virtual_host_id.value(); + let virtual_host_id = virtual_host_id.as_str(); let virtual_hosts = user_config.virtual_hosts.len(); debug!( "AuthorizedCallValidator::validate - mcp virtual host config missing call_name = {call_name} virtual_host_id = {virtual_host_id} virtual_hosts = {virtual_hosts}" @@ -104,7 +104,7 @@ impl<'a> InitializeCallValidator<'a> { let virtual_hosts = maybe_user_config.map_or(0, |user_config| user_config.virtual_hosts.len()); let has_session_id = true; let has_claims = maybe_claims.is_some(); - let virtual_host_id = maybe_virtual_host_id.map_or("", |id| id.value().as_str()); + let virtual_host_id = maybe_virtual_host_id.map_or("", VirtualHostId::as_str); debug!( "InitializeCallValidator::validate - mcp call validation call_name = {call_name} has_user_config = {has_user_config} virtual_hosts = {virtual_hosts} has_session_id = {has_session_id} has_claims = {has_claims} virtual_host_id = {virtual_host_id}" ); @@ -125,9 +125,9 @@ impl<'a> InitializeCallValidator<'a> { }); }; - let Some(virtual_host) = user_config.virtual_hosts.get(virtual_host_id.value()) else { + let Some(virtual_host) = user_config.virtual_hosts.get(virtual_host_id.as_str()) else { let call_name = "initialize"; - let virtual_host_id = virtual_host_id.value(); + let virtual_host_id = virtual_host_id.as_str(); let virtual_hosts = user_config.virtual_hosts.len(); debug!( "InitializeCallValidator::validate - mcp virtual host config missing call_name = {call_name} virtual_host_id = {virtual_host_id} virtual_hosts = {virtual_hosts}" diff --git a/crates/contextforge-data-plane-lib/src/layers/virtual_host_config.rs b/crates/contextforge-data-plane-lib/src/layers/virtual_host_config.rs index 7634e58d..c6a18c07 100644 --- a/crates/contextforge-data-plane-lib/src/layers/virtual_host_config.rs +++ b/crates/contextforge-data-plane-lib/src/layers/virtual_host_config.rs @@ -14,7 +14,7 @@ pub async fn virtual_host_config_layer(request: http::Request, if let (Some(virtual_host_id), Some(user_config)) = (virtual_host_id, user_config) && !has_virtual_host(user_config, virtual_host_id) { - let virtual_host_id = virtual_host_id.value(); + let virtual_host_id = virtual_host_id.as_str(); let virtual_hosts = user_config.virtual_hosts.len(); debug!( "virtual_host_config_layer - virtual host config missing virtual_host_id = {virtual_host_id} virtual_hosts = {virtual_hosts}" @@ -26,7 +26,7 @@ pub async fn virtual_host_config_layer(request: http::Request, } fn has_virtual_host(user_config: &UserConfig, virtual_host_id: &VirtualHostId) -> bool { - user_config.virtual_hosts.contains_key(virtual_host_id.value()) + user_config.virtual_hosts.contains_key(virtual_host_id.as_str()) } fn server_not_found_response() -> Response { diff --git a/crates/contextforge-data-plane-lib/src/layers/virtual_host_id.rs b/crates/contextforge-data-plane-lib/src/layers/virtual_host_id.rs index ccdf55cd..5271fc34 100644 --- a/crates/contextforge-data-plane-lib/src/layers/virtual_host_id.rs +++ b/crates/contextforge-data-plane-lib/src/layers/virtual_host_id.rs @@ -2,17 +2,20 @@ use axum::{body::Body, middleware::Next, response::Response}; use http::{StatusCode, header}; use tracing::debug; -#[derive(Clone, Debug, PartialEq, PartialOrd)] +/// Virtual-server identifier extracted from the downstream MCP route. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct VirtualHostId { value: String, } impl VirtualHostId { - pub(crate) fn new(value: String) -> Self { - Self { value } + /// Creates a virtual-server identifier from its canonical route value. + pub fn new(value: impl Into) -> Self { + Self { value: value.into() } } - pub fn value(&self) -> &String { + /// Returns the canonical route value. + pub fn as_str(&self) -> &str { &self.value } } @@ -40,7 +43,7 @@ fn extract_virtual_host_id(path: &str) -> Option { let l1 = "/servers/".len(); let l2 = path.len() - "/mcp".len(); let vh = &path[l1..l2]; - VirtualHostId::new(vh.to_owned()) + VirtualHostId::new(vh) }) } else { None @@ -52,12 +55,12 @@ mod tests { use crate::layers::virtual_host_id::{VirtualHostId, extract_virtual_host_id}; #[test] - fn test_virutal_host_extractor() { + fn extracts_only_virtual_host_from_server_mcp_routes() { assert_eq!(None, extract_virtual_host_id("/mcp/servers")); assert_eq!(None, extract_virtual_host_id("/servers")); assert_eq!(None, extract_virtual_host_id("/servers/12345_abcd-efgh/mcp/dkfjk")); assert_eq!( - Some(VirtualHostId { value: "12345_abcd-efgh".to_owned() }), + Some(VirtualHostId::new("12345_abcd-efgh")), extract_virtual_host_id("/servers/12345_abcd-efgh/mcp") ); assert_eq!(None, extract_virtual_host_id("/12345_abcd-efgh/12345_abcd-efgh/mcp")); diff --git a/crates/contextforge-data-plane-lib/src/lib.rs b/crates/contextforge-data-plane-lib/src/lib.rs index d9d51151..f1a87406 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -25,6 +25,7 @@ mod user_config_store; pub use common::{RedisClient, RedisConfig, UpstreamConnectionMode}; use gateway::{BackendTransports, McpService}; use layers::session_id::SessionId; +pub use layers::virtual_host_id::VirtualHostId; use tower_http::cors::{Any, CorsLayer}; use tower_http::trace::TraceLayer; use transports::{DownstreamTls, Tcp}; diff --git a/crates/contextforge-data-plane-lib/src/task_handle.rs b/crates/contextforge-data-plane-lib/src/task_handle.rs index 6fb2cadd..38eead2f 100644 --- a/crates/contextforge-data-plane-lib/src/task_handle.rs +++ b/crates/contextforge-data-plane-lib/src/task_handle.rs @@ -9,14 +9,16 @@ use std::{fmt, str::FromStr}; use aes_gcm_siv::{ - Aes256GcmSiv, Nonce, - aead::{Aead, KeyInit, Payload as AeadPayload}, + Aes256GcmSiv, Key, Nonce, + aead::{Aead, Generate, KeyInit, Payload as AeadPayload}, }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use rmcp::{ErrorData, model::ErrorCode}; -use secret_string::SecretString; use serde::{Deserialize, Serialize}; use thiserror::Error; +use zeroize::Zeroizing; + +use crate::VirtualHostId; const HANDLE_PREFIX: &str = "cfth1"; const HANDLE_FAMILY_PREFIX: &str = "cfth"; @@ -29,22 +31,18 @@ const TAG_LEN: usize = 16; /// The textual form is URL-safe base64 without padding and must decode to /// exactly 32 bytes. Its [`Debug`] output is always redacted. #[derive(Clone, PartialEq, Eq)] -pub struct TaskHandleKey(SecretString); - -impl TaskHandleKey { - fn bytes(&self) -> Result<[u8; KEY_LEN], TaskHandleKeyError> { - let bytes = URL_SAFE_NO_PAD.decode(self.0.value()).map_err(|_| TaskHandleKeyError)?; - bytes.try_into().map_err(|_| TaskHandleKeyError) - } -} +pub struct TaskHandleKey(Zeroizing<[u8; KEY_LEN]>); impl FromStr for TaskHandleKey { type Err = TaskHandleKeyError; fn from_str(value: &str) -> Result { - let key = Self(SecretString::new(value.to_owned())); - key.bytes()?; - Ok(key) + let mut key = Zeroizing::new([0_u8; KEY_LEN]); + let decoded_len = URL_SAFE_NO_PAD.decode_slice(value, key.as_mut()).map_err(|_| TaskHandleKeyError)?; + if decoded_len != KEY_LEN { + return Err(TaskHandleKeyError); + } + Ok(Self(key)) } } @@ -60,6 +58,34 @@ impl fmt::Debug for TaskHandleKey { #[error("task handle key must be URL-safe base64 without padding and decode to exactly 32 bytes")] pub struct TaskHandleKeyError; +macro_rules! string_identifier { + ($name:ident, $doc:literal) => { + #[doc = $doc] + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + pub struct $name(String); + + impl $name { + /// Creates an identifier from its canonical trusted value. + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Returns the canonical value. + pub fn as_str(&self) -> &str { + &self.0 + } + } + }; +} + +string_identifier!( + AuthorizationContextId, + "Canonical identity for the authenticated tenant, principal, team, and scope set." +); +string_identifier!(ConfigurationRevision, "Revision of the validated effective-configuration snapshot."); +string_identifier!(BackendId, "Stable identity of a backend in effective configuration."); +string_identifier!(BackendGeneration, "Generation of a backend's routing material."); + /// Authenticated request scope to which a task handle is bound. /// /// The authorization-context ID and configuration revision must come from the @@ -67,14 +93,18 @@ pub struct TaskHandleKeyError; /// metadata and MCP params are not trusted sources for either value. #[derive(Clone, Copy, PartialEq, Eq)] pub struct TaskHandleScope<'a> { - authorization_context_id: &'a str, - virtual_host_id: &'a str, - configuration_revision: &'a str, + authorization_context_id: &'a AuthorizationContextId, + virtual_host_id: &'a VirtualHostId, + configuration_revision: &'a ConfigurationRevision, } impl<'a> TaskHandleScope<'a> { /// Creates a scope from trusted authorization and routing context. - pub fn new(authorization_context_id: &'a str, virtual_host_id: &'a str, configuration_revision: &'a str) -> Self { + pub fn new( + authorization_context_id: &'a AuthorizationContextId, + virtual_host_id: &'a VirtualHostId, + configuration_revision: &'a ConfigurationRevision, + ) -> Self { Self { authorization_context_id, virtual_host_id, configuration_revision } } } @@ -86,23 +116,23 @@ impl<'a> TaskHandleScope<'a> { /// outstanding upstream task IDs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TaskHandleBackend<'a> { - id: &'a str, - generation: &'a str, + id: &'a BackendId, + generation: &'a BackendGeneration, } impl<'a> TaskHandleBackend<'a> { /// Creates backend identity from trusted effective configuration. - pub fn new(id: &'a str, generation: &'a str) -> Self { + pub fn new(id: &'a BackendId, generation: &'a BackendGeneration) -> Self { Self { id, generation } } /// Stable backend identifier used for routing. - pub fn id(self) -> &'a str { + pub fn id(self) -> &'a BackendId { self.id } /// Generation of the backend routing material. - pub fn generation(self) -> &'a str { + pub fn generation(self) -> &'a BackendGeneration { self.generation } } @@ -110,26 +140,26 @@ impl<'a> TaskHandleBackend<'a> { /// The route recovered from a valid task handle. #[derive(Clone, PartialEq, Eq)] pub struct TaskHandleRoute { - backend_id: String, - upstream_task_id: String, + backend_id: BackendId, + upstream_task_id: Zeroizing, } impl TaskHandleRoute { /// Stable backend ID in the caller's current effective configuration. - pub fn backend_id(&self) -> &str { + pub fn backend_id(&self) -> &BackendId { &self.backend_id } /// Original task identifier expected by the upstream backend. pub fn upstream_task_id(&self) -> &str { - &self.upstream_task_id + self.upstream_task_id.as_str() } } impl fmt::Debug for TaskHandleRoute { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("TaskHandleRoute") - .field("backend_id", &self.backend_id) + .field("backend_id", &self.backend_id.as_str()) .field("upstream_task_id", &"[REDACTED]") .finish() } @@ -141,14 +171,19 @@ impl fmt::Debug for TaskHandleRoute { /// distinguish a malformed handle from a valid handle outside its scope. #[derive(Debug, Error, PartialEq, Eq)] pub enum TaskHandleError { + /// The protected handle could not be created. #[error("failed to create task handle")] Encode, + /// The handle is malformed or fails authentication. #[error("invalid task handle")] Invalid, + /// The handle belongs to another codec version. #[error("invalid task handle")] UnsupportedVersion, + /// The handle does not belong to the authenticated request scope. #[error("invalid task handle")] WrongScope, + /// The referenced backend and generation are not currently routable. #[error("invalid task handle")] UnavailableBackend, } @@ -165,9 +200,19 @@ impl From for ErrorData { } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize)] +struct TaskHandlePayload<'a> { + authorization_context_id: &'a str, + virtual_host_id: &'a str, + configuration_revision: &'a str, + backend_id: &'a str, + backend_generation: &'a str, + upstream_task_id: &'a str, +} + +#[derive(Deserialize)] #[serde(deny_unknown_fields)] -struct TaskHandlePayload { +struct DecodedTaskHandlePayload { authorization_context_id: String, virtual_host_id: String, configuration_revision: String, @@ -184,9 +229,10 @@ pub struct TaskHandleCodec { impl TaskHandleCodec { /// Creates a codec from a validated shared key. - pub fn new(key: &TaskHandleKey) -> Result { - let cipher = Aes256GcmSiv::new_from_slice(&key.bytes()?).map_err(|_| TaskHandleKeyError)?; - Ok(Self { cipher }) + pub fn new(key: &TaskHandleKey) -> Self { + let key: &Key = (&*key.0).into(); + let cipher = Aes256GcmSiv::new(key); + Self { cipher } } /// Creates an opaque handle for one upstream task. @@ -197,25 +243,23 @@ impl TaskHandleCodec { upstream_task_id: &str, ) -> Result { let payload = TaskHandlePayload { - authorization_context_id: scope.authorization_context_id.to_owned(), - virtual_host_id: scope.virtual_host_id.to_owned(), - configuration_revision: scope.configuration_revision.to_owned(), - backend_id: backend.id.to_owned(), - backend_generation: backend.generation.to_owned(), - upstream_task_id: upstream_task_id.to_owned(), + authorization_context_id: scope.authorization_context_id.as_str(), + virtual_host_id: scope.virtual_host_id.as_str(), + configuration_revision: scope.configuration_revision.as_str(), + backend_id: backend.id.as_str(), + backend_generation: backend.generation.as_str(), + upstream_task_id, }; - let plaintext = serde_json::to_vec(&payload).map_err(|_| TaskHandleError::Encode)?; + let plaintext = Zeroizing::new(serde_json::to_vec(&payload).map_err(|_| TaskHandleError::Encode)?); - let mut nonce_bytes = [0_u8; NONCE_LEN]; - getrandom::fill(&mut nonce_bytes).map_err(|_| TaskHandleError::Encode)?; - let nonce = Nonce::from(nonce_bytes); + let nonce = Nonce::try_generate().map_err(|_| TaskHandleError::Encode)?; let ciphertext = self .cipher - .encrypt(&nonce, AeadPayload { msg: &plaintext, aad: HANDLE_PREFIX.as_bytes() }) + .encrypt(&nonce, AeadPayload { msg: plaintext.as_slice(), aad: HANDLE_PREFIX.as_bytes() }) .map_err(|_| TaskHandleError::Encode)?; let mut protected = Vec::with_capacity(NONCE_LEN + ciphertext.len()); - protected.extend_from_slice(&nonce_bytes); + protected.extend_from_slice(&nonce); protected.extend_from_slice(&ciphertext); Ok(format!("{HANDLE_PREFIX}.{}", URL_SAFE_NO_PAD.encode(protected))) } @@ -248,23 +292,27 @@ impl TaskHandleCodec { let (nonce_bytes, ciphertext) = protected.split_at(NONCE_LEN); let nonce_bytes: [u8; NONCE_LEN] = nonce_bytes.try_into().map_err(|_| TaskHandleError::Invalid)?; let nonce = Nonce::from(nonce_bytes); - let plaintext = self - .cipher - .decrypt(&nonce, AeadPayload { msg: ciphertext, aad: HANDLE_PREFIX.as_bytes() }) - .map_err(|_| TaskHandleError::Invalid)?; - let payload: TaskHandlePayload = serde_json::from_slice(&plaintext).map_err(|_| TaskHandleError::Invalid)?; + let plaintext = Zeroizing::new( + self.cipher + .decrypt(&nonce, AeadPayload { msg: ciphertext, aad: HANDLE_PREFIX.as_bytes() }) + .map_err(|_| TaskHandleError::Invalid)?, + ); + let payload: DecodedTaskHandlePayload = + serde_json::from_slice(&plaintext).map_err(|_| TaskHandleError::Invalid)?; - if payload.authorization_context_id != expected_scope.authorization_context_id - || payload.virtual_host_id != expected_scope.virtual_host_id - || payload.configuration_revision != expected_scope.configuration_revision + if payload.authorization_context_id != expected_scope.authorization_context_id.as_str() + || payload.virtual_host_id != expected_scope.virtual_host_id.as_str() + || payload.configuration_revision != expected_scope.configuration_revision.as_str() { return Err(TaskHandleError::WrongScope); } - if !backend_is_current(TaskHandleBackend::new(&payload.backend_id, &payload.backend_generation)) { + let backend_id = BackendId::new(payload.backend_id); + let backend_generation = BackendGeneration::new(payload.backend_generation); + if !backend_is_current(TaskHandleBackend::new(&backend_id, &backend_generation)) { return Err(TaskHandleError::UnavailableBackend); } - Ok(TaskHandleRoute { backend_id: payload.backend_id, upstream_task_id: payload.upstream_task_id }) + Ok(TaskHandleRoute { backend_id, upstream_task_id: Zeroizing::new(payload.upstream_task_id) }) } } @@ -276,37 +324,48 @@ fn is_other_version(prefix: &str) -> bool { #[cfg(test)] mod tests { + use std::sync::LazyLock; + use super::*; const KEY: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; // pragma: allowlist secret - const AUTHORIZATION_CONTEXT: &str = "tenant-a:principal-a:team-a:scope-set-a"; - const VIRTUAL_HOST_ID: &str = "host-a"; - const CONFIGURATION_REVISION: &str = "revision-7"; - const BACKEND_ID: &str = "backend-a"; - const BACKEND_GENERATION: &str = "generation-3"; + const AUTHORIZATION_CONTEXT_VALUE: &str = "tenant-a:principal-a:team-a:scope-set-a"; + const VIRTUAL_HOST_ID_VALUE: &str = "host-a"; + const CONFIGURATION_REVISION_VALUE: &str = "revision-7"; + const BACKEND_ID_VALUE: &str = "backend-a"; + const BACKEND_GENERATION_VALUE: &str = "generation-3"; + + static AUTHORIZATION_CONTEXT: LazyLock = + LazyLock::new(|| AuthorizationContextId::new(AUTHORIZATION_CONTEXT_VALUE)); + static VIRTUAL_HOST_ID: LazyLock = LazyLock::new(|| VirtualHostId::new(VIRTUAL_HOST_ID_VALUE)); + static CONFIGURATION_REVISION: LazyLock = + LazyLock::new(|| ConfigurationRevision::new(CONFIGURATION_REVISION_VALUE)); + static BACKEND_ID: LazyLock = LazyLock::new(|| BackendId::new(BACKEND_ID_VALUE)); + static BACKEND_GENERATION: LazyLock = + LazyLock::new(|| BackendGeneration::new(BACKEND_GENERATION_VALUE)); fn codec() -> TaskHandleCodec { - TaskHandleCodec::new(&KEY.parse().expect("test key is valid")).expect("test key initializes AES-256-GCM-SIV") + TaskHandleCodec::new(&KEY.parse().expect("test key is valid")) } fn scope<'a>( - authorization_context_id: &'a str, - virtual_host_id: &'a str, - configuration_revision: &'a str, + authorization_context_id: &'a AuthorizationContextId, + virtual_host_id: &'a VirtualHostId, + configuration_revision: &'a ConfigurationRevision, ) -> TaskHandleScope<'a> { TaskHandleScope::new(authorization_context_id, virtual_host_id, configuration_revision) } fn current_scope() -> TaskHandleScope<'static> { - scope(AUTHORIZATION_CONTEXT, VIRTUAL_HOST_ID, CONFIGURATION_REVISION) + scope(&AUTHORIZATION_CONTEXT, &VIRTUAL_HOST_ID, &CONFIGURATION_REVISION) } - fn backend<'a>(id: &'a str, generation: &'a str) -> TaskHandleBackend<'a> { + fn backend<'a>(id: &'a BackendId, generation: &'a BackendGeneration) -> TaskHandleBackend<'a> { TaskHandleBackend::new(id, generation) } fn current_backend() -> TaskHandleBackend<'static> { - backend(BACKEND_ID, BACKEND_GENERATION) + backend(&BACKEND_ID, &BACKEND_GENERATION) } fn decode_current( @@ -330,7 +389,7 @@ mod tests { let handle = codec.encode(current_scope(), current_backend(), task_id).expect("handle encodes"); let route = decode_current(&codec, &handle, current_scope(), current_backend()).expect("handle decodes"); - assert_eq!(route.backend_id(), BACKEND_ID); + assert_eq!(route.backend_id().as_str(), BACKEND_ID_VALUE); assert_eq!(route.upstream_task_id(), task_id); } } @@ -338,17 +397,21 @@ mod tests { #[test] fn identical_task_ids_from_different_backends_remain_isolated() { let codec = codec(); - let backend_a = backend("backend-a", "generation-a"); - let backend_b = backend("backend-b", "generation-b"); - let handle_a = codec.encode(current_scope(), backend_a, "same-id").expect("handle A encodes"); - let handle_b = codec.encode(current_scope(), backend_b, "same-id").expect("handle B encodes"); - - let route_a = decode_current(&codec, &handle_a, current_scope(), backend_a).expect("handle A decodes"); - let route_b = decode_current(&codec, &handle_b, current_scope(), backend_b).expect("handle B decodes"); - - assert_ne!(handle_a, handle_b); - assert_eq!(route_a.backend_id(), "backend-a"); - assert_eq!(route_b.backend_id(), "backend-b"); + let first_identity = (BackendId::new("backend-a"), BackendGeneration::new("generation-a")); + let second_identity = (BackendId::new("backend-b"), BackendGeneration::new("generation-b")); + let first_backend = backend(&first_identity.0, &first_identity.1); + let second_backend = backend(&second_identity.0, &second_identity.1); + let first_handle = codec.encode(current_scope(), first_backend, "same-id").expect("first handle encodes"); + let second_handle = codec.encode(current_scope(), second_backend, "same-id").expect("second handle encodes"); + + let first_route = + decode_current(&codec, &first_handle, current_scope(), first_backend).expect("first handle decodes"); + let second_route = + decode_current(&codec, &second_handle, current_scope(), second_backend).expect("second handle decodes"); + + assert_ne!(first_handle, second_handle); + assert_eq!(first_route.backend_id().as_str(), "backend-a"); + assert_eq!(second_route.backend_id().as_str(), "backend-b"); } #[test] @@ -404,11 +467,14 @@ mod tests { fn authorization_virtual_host_and_revision_scope_are_enforced() { let codec = codec(); let handle = codec.encode(current_scope(), current_backend(), "task-42").expect("handle encodes"); + let other_authorization_context = AuthorizationContextId::new("tenant-b:principal-a:team-a:scope-set-a"); + let other_virtual_host_id = VirtualHostId::new("host-b"); + let other_configuration_revision = ConfigurationRevision::new("revision-8"); for wrong_scope in [ - scope("tenant-b:principal-a:team-a:scope-set-a", VIRTUAL_HOST_ID, CONFIGURATION_REVISION), - scope(AUTHORIZATION_CONTEXT, "host-b", CONFIGURATION_REVISION), - scope(AUTHORIZATION_CONTEXT, VIRTUAL_HOST_ID, "revision-8"), + scope(&other_authorization_context, &VIRTUAL_HOST_ID, &CONFIGURATION_REVISION), + scope(&AUTHORIZATION_CONTEXT, &other_virtual_host_id, &CONFIGURATION_REVISION), + scope(&AUTHORIZATION_CONTEXT, &VIRTUAL_HOST_ID, &other_configuration_revision), ] { assert_eq!( decode_current(&codec, &handle, wrong_scope, current_backend()), @@ -424,22 +490,25 @@ mod tests { assert!(decode_current(&codec, &handle, current_scope(), current_backend()).is_ok()); let removed = codec.decode(&handle, current_scope(), |_| false).expect_err("removed backend is rejected"); - let reassigned = decode_current(&codec, &handle, current_scope(), backend(BACKEND_ID, "generation-4")) + let reassigned_generation = BackendGeneration::new("generation-4"); + let reassigned = decode_current(&codec, &handle, current_scope(), backend(&BACKEND_ID, &reassigned_generation)) .expect_err("reassigned backend is rejected"); for error in [removed, reassigned] { assert_eq!(error, TaskHandleError::UnavailableBackend); assert_eq!(error.to_string(), "invalid task handle"); - assert!(!error.to_string().contains(BACKEND_ID)); - assert!(!error.to_string().contains(BACKEND_GENERATION)); + assert!(!error.to_string().contains(BACKEND_ID_VALUE)); + assert!(!error.to_string().contains(BACKEND_GENERATION_VALUE)); } } #[test] fn invalid_keys_and_sensitive_debug_output_are_redacted() { - assert_eq!("short".parse::(), Err(TaskHandleKeyError)); + assert!("short".parse::().is_err()); + assert!(format!("{KEY}=").parse::().is_err()); + assert!(format!("{KEY}AA").parse::().is_err()); let key: TaskHandleKey = KEY.parse().expect("test key is valid"); - let codec = TaskHandleCodec::new(&key).expect("test key initializes codec"); + let codec = TaskHandleCodec::new(&key); let handle = codec.encode(current_scope(), current_backend(), "bearer-task-id").expect("handle encodes"); let route = decode_current(&codec, &handle, current_scope(), current_backend()).expect("handle decodes");