diff --git a/Cargo.lock b/Cargo.lock index 5eae2ef..35deb42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,6 +7,7 @@ name = "accord-core" version = "0.1.0" dependencies = [ "chrono", + "governance-types", "serde", "serde_yaml", "sha2", @@ -390,9 +391,12 @@ dependencies = [ "chrono", "clap", "dashmap", + "governance-types", "hex", "hfl-types", "jsonwebtoken", + "k8s-openapi", + "kube", "rand", "reqwest", "rmp-serde", @@ -689,6 +693,7 @@ dependencies = [ "accord-core", "async-trait", "chrono", + "governance-types", "hex", "registry-protocol", "serde", @@ -1600,6 +1605,17 @@ dependencies = [ "polyval", ] +[[package]] +name = "governance-types" +version = "0.1.0" +dependencies = [ + "hex", + "serde", + "serde_json", + "sha1", + "sha2", +] + [[package]] name = "group" version = "0.13.0" diff --git a/bascule-agent/Cargo.toml b/bascule-agent/Cargo.toml index 4a53f4e..1cc9c00 100644 --- a/bascule-agent/Cargo.toml +++ b/bascule-agent/Cargo.toml @@ -31,6 +31,11 @@ async-trait = { workspace = true } # Cross-workspace path deps — substrate crates substrate-rt = { path = "../../substrate/crates/substrate-rt" } hfl-types = { path = "../../substrate/crates/hfl-types", features = ["serde", "agent-extensions"] } +governance-types = { path = "../../substrate/crates/governance-types" } + +# Kubernetes (for posture ConfigMap reader) +kube = { workspace = true } +k8s-openapi = { workspace = true } # Msgpack — retained for convenience constructors and legacy decode paths rmp-serde = "1" diff --git a/bascule-agent/src/config.rs b/bascule-agent/src/config.rs index f1d8618..ebd9a24 100644 --- a/bascule-agent/src/config.rs +++ b/bascule-agent/src/config.rs @@ -131,11 +131,29 @@ pub struct SecretsNamespaceConfig { #[derive(Debug, Clone, Default, Deserialize)] pub struct AttestationNamespaceConfig { + /// Source of posture data: `"config"` (ConfigMap) or `"static"` (hardcoded). #[serde(default = "default_posture_source")] pub posture_source: String, + /// Static posture level name when `posture_source = "static"`. #[serde(default = "default_posture_level")] pub default_posture: String, + + /// Static posture wire value (1-5) when `posture_source = "static"`. + #[serde(default = "default_static_posture_wire")] + pub static_posture_wire: u8, + + /// ConfigMap name to read posture from (default: `"posture-current"`). + #[serde(default = "default_configmap_name")] + pub configmap_name: String, + + /// Namespace to read ConfigMap from (default: `"guildhouse-infra"`). + #[serde(default = "default_configmap_namespace")] + pub configmap_namespace: String, + + /// Cache TTL in seconds for ConfigMap reads (default: 30). + #[serde(default = "default_cache_ttl_secs")] + pub cache_ttl_secs: u64, } #[derive(Debug, Clone, Deserialize)] @@ -197,7 +215,19 @@ fn default_posture_source() -> String { "config".into() } fn default_posture_level() -> String { - "standard".into() + "normal".into() +} +fn default_static_posture_wire() -> u8 { + 5 // Normal +} +fn default_configmap_name() -> String { + "posture-current".into() +} +fn default_configmap_namespace() -> String { + std::env::var("BASCULE_NAMESPACE").unwrap_or_else(|_| "guildhouse-infra".into()) +} +fn default_cache_ttl_secs() -> u64 { + 30 } fn default_scope() -> String { "operate".into() diff --git a/bascule-agent/src/main.rs b/bascule-agent/src/main.rs index d5575e9..85b9fed 100644 --- a/bascule-agent/src/main.rs +++ b/bascule-agent/src/main.rs @@ -19,6 +19,7 @@ mod command_filter; mod config; mod governance_server; mod namespace; +mod posture_reader; mod session_store; mod shellstream; mod ssh_server; @@ -90,12 +91,33 @@ async fn main() -> anyhow::Result<()> { Namespace::Governance, Arc::new(namespace::governance::GovernanceHandler::new(dev_mode)), ); - router.register( - Namespace::Attestation, - Arc::new(namespace::attestation::AttestationHandler::new( + let attestation_handler = if dev_mode + || config.agent.namespaces.attestation.posture_source == "static" + { + info!( + level = %config.agent.namespaces.attestation.default_posture, + wire = config.agent.namespaces.attestation.static_posture_wire, + "Attestation handler: static mode" + ); + namespace::attestation::AttestationHandler::new_static( config.agent.namespaces.attestation.default_posture.clone(), - )), - ); + config.agent.namespaces.attestation.static_posture_wire, + ) + } else { + info!( + configmap = %config.agent.namespaces.attestation.configmap_name, + namespace = %config.agent.namespaces.attestation.configmap_namespace, + cache_ttl = config.agent.namespaces.attestation.cache_ttl_secs, + "Attestation handler: ConfigMap mode" + ); + let reader = posture_reader::PostureReader::new( + config.agent.namespaces.attestation.configmap_namespace.clone(), + config.agent.namespaces.attestation.configmap_name.clone(), + config.agent.namespaces.attestation.cache_ttl_secs, + ); + namespace::attestation::AttestationHandler::new_configmap(Arc::new(reader)) + }; + router.register(Namespace::Attestation, Arc::new(attestation_handler)); router.register( Namespace::Audit, Arc::new(namespace::audit::AuditHandler::new()), diff --git a/bascule-agent/src/namespace/attestation.rs b/bascule-agent/src/namespace/attestation.rs index 6fed669..9cc5e5a 100644 --- a/bascule-agent/src/namespace/attestation.rs +++ b/bascule-agent/src/namespace/attestation.rs @@ -1,18 +1,52 @@ +// Copyright 2026 Guildhouse Dev +// SPDX-License-Identifier: Apache-2.0 + //! ATTESTATION namespace (0x0005) — posture level + soft SAT generation. +//! +//! Two operating modes controlled by `posture_source` in config: +//! - `"static"` — returns a configured posture level (dev/test, no cluster) +//! - `"config"` — reads the `posture-current` ConfigMap via [`PostureReader`] + +use std::sync::Arc; use async_trait::async_trait; use chrono::Utc; -use crate::shellstream::{attestation, ShellstreamResponse}; use super::NamespaceHandler; +use crate::posture_reader::PostureReader; +use crate::shellstream::{attestation, ShellstreamResponse}; + +/// Source of posture data for the attestation handler. +pub enum PostureSource { + /// Static posture level for dev/test (no cluster required). + Static { + level_name: String, + level_wire: u8, + }, + /// Read posture from the posture-current ConfigMap. + ConfigMap { reader: Arc }, +} pub struct AttestationHandler { - default_posture: String, + source: PostureSource, } impl AttestationHandler { - pub fn new(default_posture: String) -> Self { - Self { default_posture } + /// Create a handler with static posture level (for dev/test mode). + pub fn new_static(level_name: String, level_wire: u8) -> Self { + Self { + source: PostureSource::Static { + level_name, + level_wire, + }, + } + } + + /// Create a handler backed by the posture-current ConfigMap. + pub fn new_configmap(reader: Arc) -> Self { + Self { + source: PostureSource::ConfigMap { reader }, + } } } @@ -38,10 +72,30 @@ impl NamespaceHandler for AttestationHandler { } impl AttestationHandler { + /// Resolve the current posture level and source label. + async fn get_posture_info(&self) -> (String, u8, &'static str) { + match &self.source { + PostureSource::Static { + level_name, + level_wire, + } => (level_name.clone(), *level_wire, "static"), + PostureSource::ConfigMap { reader } => { + let (level, source) = reader.get_posture().await; + let name = serde_json::to_value(level) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| "unknown".into()); + (name, level.to_wire(), source) + } + } + } + async fn posture(&self, session_id: &[u8; 16]) -> ShellstreamResponse { + let (level_name, level_wire, source) = self.get_posture_info().await; let response = rmp_serde::to_vec(&serde_json::json!({ - "level": self.default_posture, - "source": "config", + "level": level_name, + "level_wire": level_wire, + "source": source, "timestamp": Utc::now().to_rfc3339(), })) .unwrap_or_default(); @@ -49,26 +103,115 @@ impl AttestationHandler { } async fn sat_bundle(&self, session_id: &[u8; 16]) -> ShellstreamResponse { - // Soft SAT: a JSON bundle with session info (not cryptographically bound) + let (level_name, _, source) = self.get_posture_info().await; let bundle_id = uuid::Uuid::new_v4().to_string(); + let soft_mode = matches!(&self.source, PostureSource::Static { .. }); let response = rmp_serde::to_vec(&serde_json::json!({ "bundle_id": bundle_id, "session_id": hex::encode(session_id), - "posture": self.default_posture, + "posture": level_name, "issued_at": Utc::now().to_rfc3339(), - "soft_mode": true, + "soft_mode": soft_mode, + "source": source, })) .unwrap_or_default(); ShellstreamResponse::ok(*session_id, 0, response) } async fn verify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse { - // Soft mode: always returns valid (no TPM to verify against) + // Verify remains soft-mode until real TPM binding is implemented + let soft_mode = matches!(&self.source, PostureSource::Static { .. }); let response = rmp_serde::to_vec(&serde_json::json!({ "valid": true, - "soft_mode": true, + "soft_mode": soft_mode, })) .unwrap_or_default(); ShellstreamResponse::ok(*session_id, 0, response) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn static_handler_returns_configured_level() { + let handler = AttestationHandler::new_static("elevated".into(), 4); + let session_id = [0u8; 16]; + let resp = handler.handle(attestation::POSTURE, &[], &session_id).await; + assert_eq!(resp.status, 0x00); // Ok + let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap(); + assert_eq!(value["level"], "elevated"); + assert_eq!(value["level_wire"], 4); + assert_eq!(value["source"], "static"); + } + + #[tokio::test] + async fn static_sat_bundle_includes_posture() { + let handler = AttestationHandler::new_static("normal".into(), 5); + let session_id = [0xAA; 16]; + let resp = handler + .handle(attestation::SAT_BUNDLE, &[], &session_id) + .await; + assert_eq!(resp.status, 0x00); + let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap(); + assert_eq!(value["posture"], "normal"); + assert_eq!(value["soft_mode"], true); + assert!(value["bundle_id"].is_string()); + } + + #[tokio::test] + async fn static_verify_returns_soft_mode() { + let handler = AttestationHandler::new_static("normal".into(), 5); + let session_id = [0u8; 16]; + let resp = handler + .handle(attestation::ATTESTATION_VERIFY, &[], &session_id) + .await; + assert_eq!(resp.status, 0x00); + let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap(); + assert_eq!(value["valid"], true); + assert_eq!(value["soft_mode"], true); + } + + #[tokio::test] + async fn configmap_handler_falls_back_without_cluster() { + let reader = Arc::new(PostureReader::new( + "test-ns".into(), + "posture-current".into(), + 30, + )); + let handler = AttestationHandler::new_configmap(reader); + let session_id = [0u8; 16]; + let resp = handler.handle(attestation::POSTURE, &[], &session_id).await; + assert_eq!(resp.status, 0x00); + let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap(); + // Without a cluster, falls back to lockdown + assert_eq!(value["level"], "lockdown"); + assert_eq!(value["level_wire"], 1); + assert_eq!(value["source"], "fallback"); + } + + #[tokio::test] + async fn configmap_verify_not_soft_mode() { + let reader = Arc::new(PostureReader::new( + "test-ns".into(), + "posture-current".into(), + 30, + )); + let handler = AttestationHandler::new_configmap(reader); + let session_id = [0u8; 16]; + let resp = handler + .handle(attestation::ATTESTATION_VERIFY, &[], &session_id) + .await; + let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap(); + assert_eq!(value["soft_mode"], false); + } + + #[tokio::test] + async fn unknown_function_returns_error() { + let handler = AttestationHandler::new_static("normal".into(), 5); + let session_id = [0u8; 16]; + let resp = handler.handle(0xFFFF, &[], &session_id).await; + assert_eq!(resp.status, 0x01); // Error + } +} diff --git a/bascule-agent/src/posture_reader.rs b/bascule-agent/src/posture_reader.rs new file mode 100644 index 0000000..d6bb36e --- /dev/null +++ b/bascule-agent/src/posture_reader.rs @@ -0,0 +1,183 @@ +// Copyright 2026 Guildhouse Dev +// SPDX-License-Identifier: Apache-2.0 + +//! Reads posture state from the `posture-current` ConfigMap. +//! +//! The ConfigMap is written by the posture evaluator in substrate-operator +//! and contains the cluster's operational posture level. This module does +//! NOT depend on `keylime-client` — it reads the ConfigMap JSON directly +//! to keep the dependency graph clean. + +use governance_types::PostureLevel; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; +use tracing::{debug, warn}; + +/// Reads posture level from the `posture-current` ConfigMap. +/// +/// Caches the last successful read for a configurable TTL to avoid +/// hammering the Kubernetes API server on every Shellstream POSTURE +/// request. +pub struct PostureReader { + client: tokio::sync::OnceCell, + namespace: String, + configmap_name: String, + cache: RwLock>, + cache_ttl: Duration, +} + +struct CachedPosture { + level: PostureLevel, + fetched_at: Instant, +} + +impl PostureReader { + pub fn new(namespace: String, configmap_name: String, cache_ttl_secs: u64) -> Self { + Self { + client: tokio::sync::OnceCell::new(), + namespace, + configmap_name, + cache: RwLock::new(None), + cache_ttl: Duration::from_secs(cache_ttl_secs), + } + } + + /// Get the current posture level. + /// + /// Returns `(PostureLevel, source_label)` where source_label is one of: + /// - `"configmap"` — fresh read from Kubernetes + /// - `"configmap-cached"` — served from cache within TTL + /// - `"configmap-stale"` — cache expired but fresh read failed + /// - `"fallback"` — no data available, returning Lockdown (fail-closed) + pub async fn get_posture(&self) -> (PostureLevel, &'static str) { + // Check cache first + { + let cache = self.cache.read().await; + if let Some(c) = cache.as_ref() { + if c.fetched_at.elapsed() < self.cache_ttl { + return (c.level, "configmap-cached"); + } + } + } + + // Cache miss or expired — read from ConfigMap + match self.read_from_configmap().await { + Ok(level) => { + debug!( + level = ?level, + configmap = %self.configmap_name, + "posture read from ConfigMap" + ); + *self.cache.write().await = Some(CachedPosture { + level, + fetched_at: Instant::now(), + }); + (level, "configmap") + } + Err(e) => { + warn!( + error = %e, + configmap = %self.configmap_name, + namespace = %self.namespace, + "posture ConfigMap read failed" + ); + // Serve stale cache if available, otherwise fail-closed + let cache = self.cache.read().await; + match cache.as_ref() { + Some(c) => (c.level, "configmap-stale"), + None => (PostureLevel::Lockdown, "fallback"), + } + } + } + } + + async fn read_from_configmap(&self) -> Result { + let client = self + .client + .get_or_try_init(|| async { + kube::Client::try_default() + .await + .map_err(|e| format!("kube client init: {e}")) + }) + .await?; + + use k8s_openapi::api::core::v1::ConfigMap; + use kube::api::Api; + + let api: Api = Api::namespaced(client.clone(), &self.namespace); + let cm = api + .get(&self.configmap_name) + .await + .map_err(|e| format!("get '{}': {e}", self.configmap_name))?; + + let level_str = cm + .data + .as_ref() + .and_then(|d| d.get("level")) + .ok_or_else(|| "ConfigMap missing 'level' key".to_string())?; + + let level_u8: u8 = level_str + .parse() + .map_err(|_| format!("invalid level value: '{level_str}'"))?; + + PostureLevel::from_wire(level_u8) + .ok_or_else(|| format!("level {level_u8} out of range 1-5")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn posture_level_wire_mapping() { + // Verify the mapping we rely on from governance-types + assert_eq!(PostureLevel::from_wire(1), Some(PostureLevel::Lockdown)); + assert_eq!(PostureLevel::from_wire(5), Some(PostureLevel::Normal)); + assert_eq!(PostureLevel::from_wire(0), None); + assert_eq!(PostureLevel::from_wire(6), None); + } + + #[test] + fn posture_level_serde_names() { + // Verify the snake_case names we use in Shellstream responses + let json = serde_json::to_value(PostureLevel::Normal).unwrap(); + assert_eq!(json, "normal"); + + let json = serde_json::to_value(PostureLevel::Lockdown).unwrap(); + assert_eq!(json, "lockdown"); + + let json = serde_json::to_value(PostureLevel::Restricted).unwrap(); + assert_eq!(json, "restricted"); + } + + #[tokio::test] + async fn reader_returns_fallback_without_cluster() { + let reader = PostureReader::new( + "test-ns".into(), + "posture-current".into(), + 30, + ); + // No kube cluster available — should fall back to Lockdown + let (level, source) = reader.get_posture().await; + assert_eq!(level, PostureLevel::Lockdown); + assert_eq!(source, "fallback"); + } + + #[tokio::test] + async fn reader_caches_fallback_result() { + // Verify that multiple calls don't spam warnings + let reader = PostureReader::new( + "test-ns".into(), + "posture-current".into(), + 30, + ); + let (l1, s1) = reader.get_posture().await; + // The fallback isn't cached (it's returned inline), but the kube + // client OnceCell retries on failure, so this will attempt again + let (l2, _s2) = reader.get_posture().await; + assert_eq!(l1, l2); + assert_eq!(l1, PostureLevel::Lockdown); + assert_eq!(s1, "fallback"); + } +}