diff --git a/crates/tinysecurity-approval/src/automatic_tests.rs b/crates/tinysecurity-approval/src/automatic_tests.rs index d9a657cf..9d46ba97 100644 --- a/crates/tinysecurity-approval/src/automatic_tests.rs +++ b/crates/tinysecurity-approval/src/automatic_tests.rs @@ -47,7 +47,7 @@ impl tinysecurity_auto::Clock for TestClock { } fn judge_config() -> JudgeConfig { JudgeConfig { - credential: Secret("fake-judge-key".into()), + credential: Some(Secret("fake-judge-key".into())), endpoint: "http://127.0.0.1:1/jev".into(), provider: JudgeProvider::TypeSafe, threshold_millis: 900, diff --git a/crates/tinysecurity-approval/src/judge_matrix_tests.rs b/crates/tinysecurity-approval/src/judge_matrix_tests.rs index ddb54d52..b9881c1b 100644 --- a/crates/tinysecurity-approval/src/judge_matrix_tests.rs +++ b/crates/tinysecurity-approval/src/judge_matrix_tests.rs @@ -46,7 +46,7 @@ impl tinysecurity_auto::JudgeClient for Stub { } fn config(origins: Vec, max_requests: u32) -> JudgeConfig { JudgeConfig { - credential: Secret("fake-judge-key".into()), + credential: Some(Secret("fake-judge-key".into())), endpoint: "http://127.0.0.1:1/jev".into(), provider: JudgeProvider::TypeSafe, threshold_millis: 900, diff --git a/crates/tinysecurity-auto/README.md b/crates/tinysecurity-auto/README.md index 93cba169..c9cf6a1e 100644 --- a/crates/tinysecurity-auto/README.md +++ b/crates/tinysecurity-auto/README.md @@ -19,6 +19,16 @@ lookup or per-call credential is supported. Invalid init/reinit rejects without changing the active policy. The credential and init endpoint are registered as redaction literals. Configuration, clients and prepared inputs have redacted Debug. +A host that can only read the credential through the admitted module (from its +keyring) leaves `credential` out. The module then asks the init-bound trusted host +through the confidential `JudgeCredentialHost2.Credential` callback on the first +judgement, never during load, within the judge's `timeout_ms`. It reuses the client +for five minutes (`HOST_CREDENTIAL_TTL_MS`). An endpoint rejection (401/403) drops +it at once, and the next ask says `rejected`. A reinit drops it too. No credential, +or a failed ask, sends that request to a person, the same as an unavailable judge. +A credential supplied this way arrives after init, so it is not registered as a +redaction literal; it is used only in the outbound request header. + `threshold_millis` is the default inclusive minimum for both intent and reversibility. `exfiltrate_millis` is the inclusive maximum for exfiltration. `thresholds` can override these three bounds by origin and verified local/external diff --git a/crates/tinysecurity-auto/src/error/mod.rs b/crates/tinysecurity-auto/src/error/mod.rs index 499097a3..5e5c0c10 100644 --- a/crates/tinysecurity-auto/src/error/mod.rs +++ b/crates/tinysecurity-auto/src/error/mod.rs @@ -17,6 +17,9 @@ pub enum Error { /// Transport or provider unavailable. #[error("judge unavailable")] Unavailable, + /// The endpoint rejected the credential. + #[error("judge credential rejected")] + Rejected, } /// Native judge result with a sanitized local error. pub type Result = std::result::Result; diff --git a/crates/tinysecurity-auto/src/host.rs b/crates/tinysecurity-auto/src/host.rs new file mode 100644 index 00000000..7c785ff9 --- /dev/null +++ b/crates/tinysecurity-auto/src/host.rs @@ -0,0 +1,107 @@ +//! Judge credential asked of the host when a judgement first needs it. +//! +//! A host that keeps the credential somewhere only the admitted module can read +//! (its keyring) cannot supply it at load. [`HostClient`] asks a +//! [`CredentialSource`] on the first evaluation instead, reuses the client for +//! [`HOST_CREDENTIAL_TTL_MS`], and drops it as soon as the endpoint rejects the +//! credential, so rotation and sign-out are picked up. +use crate::{Clock, Error, JudgeClient, Result, SdkClient}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use tinyinference_decisions::{EvaluationRequest, EvaluationResponse}; +use tinysecurity_bus::{JudgeConfig, Secret}; + +/// How long a host-supplied credential is reused before the host is asked again. +pub const HOST_CREDENTIAL_TTL_MS: u64 = 300_000; + +/// The trusted host's current judge credential. +#[async_trait::async_trait] +pub trait CredentialSource: Send + Sync + std::fmt::Debug { + /// `rejected` says the endpoint refused the last credential. `None`, or any + /// failure, sends this judgement to a person. + async fn credential(&self, rejected: bool) -> Option; +} + +/// Builds the outbound client for one credential. +pub(crate) type Build = + Arc Result> + Send + Sync>; + +/// The production builder: one SDK client per credential. +pub(crate) fn sdk() -> Build { + Arc::new(|config, credential| Ok(Arc::new(SdkClient::with_credential(config, credential)?))) +} + +/// A [`JudgeClient`] whose credential comes from the host on demand. +pub(crate) struct HostClient { + config: JudgeConfig, + source: Arc, + clock: Arc, + build: Build, + // Held across the host callback only, so one ask runs at a time; never + // across the outbound evaluation. + held: tokio::sync::Mutex, u64)>>, + rejected: AtomicBool, +} +impl std::fmt::Debug for HostClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("HostClient([REDACTED])") + } +} +impl HostClient { + pub(crate) fn new( + config: JudgeConfig, + source: Arc, + clock: Arc, + build: Build, + ) -> Self { + Self { + config, + source, + clock, + build, + held: tokio::sync::Mutex::new(None), + rejected: AtomicBool::new(false), + } + } + async fn client(&self) -> Result> { + let mut held = self.held.lock().await; + let now = self.clock.milliseconds(); + if let Some((client, since)) = held.as_ref() + && now.saturating_sub(*since) < HOST_CREDENTIAL_TTL_MS + { + return Ok(Arc::clone(client)); + } + *held = None; + let credential = self + .source + .credential(self.rejected.load(Ordering::SeqCst)) + .await + .filter(|secret| (4..=4096).contains(&secret.0.len())) + .ok_or(Error::Unavailable)?; + let client = (self.build)(&self.config, &credential)?; + self.rejected.store(false, Ordering::SeqCst); + *held = Some((Arc::clone(&client), now)); + Ok(client) + } +} +#[async_trait::async_trait] +impl JudgeClient for HostClient { + async fn evaluate(&self, request: &EvaluationRequest) -> Result { + let client = self.client().await?; + let result = client.evaluate(request).await; + if matches!(result, Err(Error::Rejected)) { + // Drop exactly the client that failed; a fresh one may already be held. + let mut held = self.held.lock().await; + if held + .as_ref() + .is_some_and(|(current, _)| Arc::ptr_eq(current, &client)) + { + *held = None; + } + self.rejected.store(true, Ordering::SeqCst); + } + result + } +} diff --git a/crates/tinysecurity-auto/src/judge.rs b/crates/tinysecurity-auto/src/judge.rs index b8c120b9..ec78fc74 100644 --- a/crates/tinysecurity-auto/src/judge.rs +++ b/crates/tinysecurity-auto/src/judge.rs @@ -1,5 +1,10 @@ //! Redaction-first Jev requests, exact score binding and total deadlines. -use crate::{Clock, Error, Result, budget::Budget, cache::Cache}; +use crate::{ + Clock, Error, Result, + budget::Budget, + cache::Cache, + host::{self, CredentialSource, HostClient}, +}; use sha2::{Digest, Sha256}; use std::{ sync::{Arc, Mutex}, @@ -10,7 +15,7 @@ use tinyinference_decisions::{ }; use tinysecurity_bus::{ ApprovalBinding2, JudgeConfig, JudgeEvidence2, JudgeOutcome2, JudgeProvider, JudgeThresholds, - RedactMode, RedactRequest, + RedactMode, RedactRequest, Secret, }; use tinysecurity_redact::Redactor; const QUESTIONS: [&str; 3] = ["intent", "reversible", "exfiltrate"]; @@ -55,10 +60,16 @@ impl SdkClient { /// # Errors /// Rejects invalid credentials, endpoints or timeout settings. pub fn new(config: &JudgeConfig) -> Result { + Self::with_credential(config, config.credential.as_ref().ok_or(Error::Invalid)?) + } + /// The same client for a credential the host supplied after load. + /// # Errors + /// Rejects invalid credentials, endpoints or timeout settings. + pub fn with_credential(config: &JudgeConfig, credential: &Secret) -> Result { let mut client = match config.provider { - JudgeProvider::TypeSafe => ClientConfig::new(config.credential.0.clone()), - JudgeProvider::OpenRouter => ClientConfig::openrouter(config.credential.0.clone()), - JudgeProvider::OpenJev => ClientConfig::openjev(config.credential.0.clone()), + JudgeProvider::TypeSafe => ClientConfig::new(credential.0.clone()), + JudgeProvider::OpenRouter => ClientConfig::openrouter(credential.0.clone()), + JudgeProvider::OpenJev => ClientConfig::openjev(credential.0.clone()), } .with_endpoint_url(&config.endpoint); client.timeout = Duration::from_millis(u64::from(config.timeout_ms)); @@ -73,7 +84,18 @@ impl JudgeClient for SdkClient { .evaluate(request) .await .map(|r| r.response) - .map_err(|_| Error::Unavailable) + .map_err(|error| classify(&error)) + } +} +/// A rejected credential (401/403) is told apart; every other failure is +/// `Unavailable`. The client wraps each failed evaluation with its attempt +/// count, so the classified cause is inside. +fn classify(error: &tinyinference_decisions::Error) -> Error { + use tinyinference_decisions::Error as Sdk; + match error { + Sdk::EvaluationFailure(failure) => classify(&failure.error), + Sdk::Authentication => Error::Rejected, + _ => Error::Unavailable, } } /// Shared bounded native scorer; locks protect reservations only, never HTTP waits. @@ -83,6 +105,7 @@ pub struct Engine { client: Arc, clock: Arc, state: Arc>, + source: Option>, } impl std::fmt::Debug for Engine { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -97,6 +120,39 @@ impl Engine { let client = Arc::new(SdkClient::new(&config)?); Self::with_client(config, client, clock) } + /// Construct a production scorer that, when `config` carries no + /// credential, asks `source` for it on the first judgement and reuses it + /// for [`host::HOST_CREDENTIAL_TTL_MS`]. A configured credential is used + /// as by [`Engine::new`]. + /// # Errors + /// Rejects invalid bounded settings and endpoint/key configuration. + pub fn with_credential_source( + config: JudgeConfig, + source: Arc, + clock: Arc, + ) -> Result { + Self::with_source(config, Some(source), clock, host::sdk()) + } + fn with_source( + config: JudgeConfig, + source: Option>, + clock: Arc, + build: host::Build, + ) -> Result { + let client: Arc = match (&config.credential, &source) { + (Some(credential), _) => build(&config, credential)?, + (None, Some(source)) => Arc::new(HostClient::new( + config.clone(), + Arc::clone(source), + Arc::clone(&clock), + build, + )), + (None, None) => return Err(Error::Invalid), + }; + let mut engine = Self::with_client(config, client, clock)?; + engine.source = source; + Ok(engine) + } /// Inject a one-attempt typed evaluator and monotonic clock. /// # Errors /// Rejects invalid limits, duplicate origins/rules and local score bounds. @@ -117,6 +173,7 @@ impl Engine { client, clock, state, + source: None, }) } /// Validate the complete explicit initialization before activation. @@ -124,13 +181,24 @@ impl Engine { /// Rejects invalid local limits or SDK endpoint/credential settings. pub fn validate_config(config: &JudgeConfig) -> Result<()> { validate(config)?; - SdkClient::new(config).map(|_| ()) + match &config.credential { + Some(credential) => SdkClient::with_credential(config, credential).map(|_| ()), + // The endpoint and timeout are checked now; the placeholder key is + // never sent, because this client is dropped unused. + None => SdkClient::with_credential(config, &Secret("unset".into())).map(|_| ()), + } } /// Prepare a new configuration while preserving the shared reservation ledger. /// # Errors /// Rejects invalid settings without mutating the current engine. pub fn reconfigured(&self, config: JudgeConfig) -> Result { - let mut candidate = Self::new(config, Arc::clone(&self.clock))?; + // A fresh client: a host credential held before the reinit is dropped. + let mut candidate = Self::with_source( + config, + self.source.clone(), + Arc::clone(&self.clock), + host::sdk(), + )?; candidate.state = Arc::clone(&self.state); candidate .state @@ -285,8 +353,10 @@ fn validate(config: &JudgeConfig) -> Result<()> { || config.cache_ttl_ms > 600_000 || config.origins.len() > 6 || config.thresholds.len() > 12 - || config.credential.0.len() < 4 - || config.credential.0.len() > 4096 + || config + .credential + .as_ref() + .is_some_and(|credential| !(4..=4096).contains(&credential.0.len())) || config.endpoint.len() > 4096 || config .origins diff --git a/crates/tinysecurity-auto/src/judge_tests.rs b/crates/tinysecurity-auto/src/judge_tests.rs index d10db632..74209f6e 100644 --- a/crates/tinysecurity-auto/src/judge_tests.rs +++ b/crates/tinysecurity-auto/src/judge_tests.rs @@ -35,7 +35,7 @@ impl JudgeClient for Fake { } fn config() -> JudgeConfig { JudgeConfig { - credential: Secret("fake-judge-key".into()), + credential: Some(Secret("fake-judge-key".into())), endpoint: "http://127.0.0.1:1/jev".into(), provider: JudgeProvider::TypeSafe, threshold_millis: 900, @@ -482,7 +482,7 @@ async fn class_origin_thresholds_invalid_config_and_reconfiguration_cannot_reuse 4 => invalid.budget_window_ms = 0, 5 => invalid.cache_entries = 1025, 6 => invalid.cache_ttl_ms = 600_001, - 7 => invalid.credential.0 = "a".into(), + 7 => invalid.credential = Some(Secret("a".into())), 8 => invalid.endpoint = "x".repeat(4097), 9 => invalid.origins.push(Origin::Operator), 10 => invalid.thresholds.push(invalid.thresholds[0].clone()), @@ -537,3 +537,178 @@ async fn legitimate_reconfiguration_shrinks_shared_cache_and_zero_clears_it() -> assert_eq!(disabled.state.lock().map_err(|_| Error::Budget)?.1.len(), 0); Ok(()) } + +#[derive(Debug, Default)] +struct Host { + asks: Mutex>, + credential: Mutex>, +} +#[async_trait::async_trait] +impl CredentialSource for Host { + async fn credential(&self, rejected: bool) -> Option { + self.asks.lock().ok()?.push(rejected); + self.credential.lock().ok()?.clone() + } +} +fn host(credential: Option<&str>) -> Arc { + Arc::new(Host { + asks: Mutex::default(), + credential: Mutex::new(credential.map(|c| Secret(c.into()))), + }) +} +fn asks(host: &Host) -> Result> { + Ok(host.asks.lock().map_err(|_| Error::Unavailable)?.clone()) +} +/// A judge with no configured credential, uncached and with room in its budget. +fn hosted(host: &Arc, client: &Arc, clock: &Arc) -> Result { + let mut cfg = config(); + cfg.credential = None; + cfg.cache_ttl_ms = 0; + cfg.max_requests = 100; + let client = Arc::clone(client); + Engine::with_source( + cfg, + Some(Arc::clone(host) as Arc), + Arc::clone(clock) as Arc, + Arc::new(move |_, _| Ok(Arc::clone(&client) as Arc)), + ) +} +async fn outcome(engine: &Engine) -> Result { + let registry = registry().map_err(|_| Error::Invalid)?; + Ok(engine + .evaluate(&input(), ®istry) + .await? + .ok_or(Error::Invalid)? + .outcome) +} + +#[tokio::test] +async fn a_hosted_judge_asks_on_first_use_and_reuses_the_credential_for_five_minutes() -> Result<()> +{ + let host = host(Some("host-held-key")); + let clock = Arc::new(TestClock::default()); + let engine = hosted(&host, &fake([0.9, 0.9, 0.1]), &clock)?; + // Construction asks nothing: the host is never called during load. + assert_eq!(asks(&host)?, Vec::::new()); + assert_eq!(outcome(&engine).await?, JudgeOutcome2::Qualified); + clock + .0 + .store(host::HOST_CREDENTIAL_TTL_MS - 1, Ordering::SeqCst); + assert_eq!(outcome(&engine).await?, JudgeOutcome2::Qualified); + assert_eq!(asks(&host)?, [false]); + clock + .0 + .store(host::HOST_CREDENTIAL_TTL_MS, Ordering::SeqCst); + assert_eq!(outcome(&engine).await?, JudgeOutcome2::Qualified); + assert_eq!(asks(&host)?, [false, false]); + Ok(()) +} + +#[tokio::test] +async fn no_usable_host_credential_sends_the_judgement_to_a_person() -> Result<()> { + let clock = Arc::new(TestClock::default()); + for credential in [None, Some("abc")] { + let host = host(credential); + let engine = hosted(&host, &fake([0.9, 0.9, 0.1]), &clock)?; + assert_eq!(outcome(&engine).await?, JudgeOutcome2::Unavailable); + assert_eq!(asks(&host)?, [false]); + } + Ok(()) +} + +#[tokio::test] +async fn a_rejected_credential_is_dropped_and_the_next_ask_says_so() -> Result<()> { + let host = host(Some("host-held-key")); + let clock = Arc::new(TestClock::default()); + let client = fake([0.9, 0.9, 0.1]); + *client.response.lock().map_err(|_| Error::Unavailable)? = Err(Error::Rejected); + let engine = hosted(&host, &client, &clock)?; + assert_eq!(outcome(&engine).await?, JudgeOutcome2::Unavailable); + *client.response.lock().map_err(|_| Error::Unavailable)? = Ok(response([0.9, 0.9, 0.1])); + assert_eq!(outcome(&engine).await?, JudgeOutcome2::Qualified); + assert_eq!(outcome(&engine).await?, JudgeOutcome2::Qualified); + // Asked again at once, flagged as a rejection, then reused. + assert_eq!(asks(&host)?, [false, true]); + Ok(()) +} + +#[tokio::test] +async fn a_reinit_drops_the_host_credential() -> Result<()> { + let host = host(Some("host-held-key")); + let clock = Arc::new(TestClock::default()); + let engine = hosted(&host, &fake([0.9, 0.9, 0.1]), &clock)?; + outcome(&engine).await?; + let mut cfg = config(); + cfg.credential = None; + let reinit = engine.reconfigured(cfg)?; + // The rebuilt engine talks to the unreachable fixture endpoint, but it has + // to ask the host again first. + outcome(&reinit).await?; + assert_eq!(asks(&host)?, [false, false]); + Ok(()) +} + +#[tokio::test] +async fn a_judge_without_credential_needs_a_host_source() -> Result<()> { + let mut cfg = config(); + cfg.credential = None; + Engine::validate_config(&cfg)?; + assert_eq!( + Engine::new(cfg.clone(), Arc::new(TestClock::default())).err(), + Some(Error::Invalid) + ); + let source: Arc = host(Some("host-held-key")); + assert!( + Engine::with_credential_source( + cfg.clone(), + Arc::clone(&source), + Arc::new(TestClock::default()) + ) + .is_ok() + ); + // A configured credential is used as configured, whatever the source. + assert!( + Engine::with_credential_source(config(), source, Arc::new(TestClock::default())).is_ok() + ); + let client = HostClient::new( + cfg.clone(), + host(None), + Arc::new(TestClock::default()), + host::sdk(), + ); + assert_eq!(format!("{client:?}"), "HostClient([REDACTED])"); + cfg.endpoint = "x".repeat(4097); + assert!(Engine::validate_config(&cfg).is_err()); + Ok(()) +} + +#[tokio::test] +async fn an_endpoint_refusing_the_credential_is_reported_as_rejected() -> Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .map_err(|_| Error::Unavailable)?; + let port = listener + .local_addr() + .map_err(|_| Error::Unavailable)? + .port(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.ok()?; + let mut buffer = vec![0; 65_536]; + let _ = stream.read(&mut buffer).await.ok()?; + stream + .write_all( + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .ok() + }); + let mut cfg = config(); + cfg.endpoint = format!("http://127.0.0.1:{port}/jev"); + let client = SdkClient::new(&cfg)?; + let registry = registry().map_err(|_| Error::Invalid)?; + let request = request(&input(), ®istry)?; + assert_eq!(client.evaluate(&request).await.err(), Some(Error::Rejected)); + let _ = server.await; + Ok(()) +} diff --git a/crates/tinysecurity-auto/src/lib.rs b/crates/tinysecurity-auto/src/lib.rs index 9f5883f3..0bde8695 100644 --- a/crates/tinysecurity-auto/src/lib.rs +++ b/crates/tinysecurity-auto/src/lib.rs @@ -13,9 +13,11 @@ mod budget; mod cache; mod error; +mod host; mod judge; pub mod rules; pub use error::{Error, Result}; +pub use host::{CredentialSource, HOST_CREDENTIAL_TTL_MS}; pub use judge::{Engine, Input, JudgeClient, SdkClient}; /// Injected monotonic time for shared budgets and exact-score cache expiration. pub trait Clock: Send + Sync + std::fmt::Debug { diff --git a/crates/tinysecurity-bus/src/callbacks.rs b/crates/tinysecurity-bus/src/callbacks.rs index 9d7eb715..c0e42cdc 100644 --- a/crates/tinysecurity-bus/src/callbacks.rs +++ b/crates/tinysecurity-bus/src/callbacks.rs @@ -205,6 +205,10 @@ pub mod callback_names2 { pub const KEYRING_CONSENT: &str = "ai.tinyhumans.tinysecurity.KeyringConsent2"; /// Exact keyring consent member. pub const CONSENT: &str = "Consent"; + /// Confidential host source of the judge credential, asked lazily. + pub const JUDGE_CREDENTIAL_HOST: &str = "ai.tinyhumans.tinysecurity.JudgeCredentialHost2"; + /// Exact judge credential member. + pub const CREDENTIAL: &str = "Credential"; /// Versioned durable audit callback. pub const AUDIT_SINK: &str = "ai.tinyhumans.tinysecurity.AuditSink2"; /// Versioned scoped storage callback seam. diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs index c958f535..6e51c3e6 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -392,8 +392,11 @@ pub struct JudgeThresholdRule { #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct JudgeConfig { - /// Init-only resolved backend credential. - pub credential: Secret, + /// Init-only resolved backend credential. Absent, the module asks the + /// trusted host through `JudgeCredentialHost2` when a judgement first + /// needs it, never during load, and reuses it for five minutes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credential: Option, /// Exact evaluation endpoint; never exposed in diagnostic output. #[serde(default, skip_serializing_if = "String::is_empty")] pub endpoint: String, @@ -425,6 +428,30 @@ pub struct JudgeConfig { #[serde(default, skip_serializing_if = "zero")] pub cache_ttl_ms: u32, } +/// Why the module asks the host for the judge credential. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JudgeCredentialReason2 { + /// No credential is held, or the held one is older than five minutes. + Initial, + /// The judge endpoint rejected the last credential the host gave. + Rejected, +} +/// `JudgeCredentialHost2.Credential` callback request; carries no secret. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JudgeCredentialRequest2 { + /// Why the credential is needed now. + pub reason: JudgeCredentialReason2, +} +/// `JudgeCredentialHost2.Credential` reply over confidential transport. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JudgeCredentialResponse2 { + /// Current credential, 4..=4096 bytes; absent sends the judgement to a person. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credential: Option, +} impl fmt::Debug for JudgeConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("JudgeConfig([REDACTED])") diff --git a/crates/tinysecurity-bus/src/policy_tests.rs b/crates/tinysecurity-bus/src/policy_tests.rs index a1ae5c84..ea7f06b0 100644 --- a/crates/tinysecurity-bus/src/policy_tests.rs +++ b/crates/tinysecurity-bus/src/policy_tests.rs @@ -190,3 +190,38 @@ fn a_1_18_check_payload_still_deserializes() -> TestResult { } Ok(()) } + +#[test] +fn judge_credential_callback_and_optional_credential_pin_their_wire_form() +-> Result<(), Box> { + assert_eq!( + serde_json::to_value(JudgeCredentialRequest2 { + reason: JudgeCredentialReason2::Rejected + })?, + serde_json::json!({"reason":"rejected"}) + ); + assert_eq!( + serde_json::to_value(JudgeCredentialRequest2 { + reason: JudgeCredentialReason2::Initial + })?, + serde_json::json!({"reason":"initial"}) + ); + let empty: JudgeCredentialResponse2 = serde_json::from_value(serde_json::json!({}))?; + assert_eq!(empty.credential, None); + assert_eq!(serde_json::to_value(&empty)?, serde_json::json!({})); + let held = JudgeCredentialResponse2 { + credential: Some(Secret("host-held-key".into())), + }; + assert_eq!( + serde_json::to_value(&held)?, + serde_json::json!({"credential":"host-held-key"}) + ); + assert!(!format!("{held:?}").contains("host-held-key")); + // A judge configured without a credential omits the field entirely. + let judge: JudgeConfig = serde_json::from_value( + serde_json::json!({"threshold_millis":900,"timeout_ms":1000,"origins":[]}), + )?; + assert_eq!(judge.credential, None); + assert!(serde_json::to_value(&judge)?.get("credential").is_none()); + Ok(()) +} diff --git a/crates/tinysecurity-module/examples/judge_native_contract.rs b/crates/tinysecurity-module/examples/judge_native_contract.rs index 4f42d0c6..d3ccebd3 100644 --- a/crates/tinysecurity-module/examples/judge_native_contract.rs +++ b/crates/tinysecurity-module/examples/judge_native_contract.rs @@ -178,7 +178,7 @@ fn begin(instance: u64, sequence: u64) -> EvaluateApprovalRequest2 { } fn judge(endpoint: String) -> JudgeConfig { JudgeConfig { - credential: Secret("fake-judge-key".into()), + credential: Some(Secret("fake-judge-key".into())), endpoint, provider: JudgeProvider::TypeSafe, threshold_millis: 900, @@ -237,7 +237,8 @@ async fn main() -> std::result::Result<(), Box> { verify_matrix(&proxy, &stub).await?; let mut invalid = configuration.clone(); invalid.judge.as_mut().ok_or("judge")?.endpoint = "http://not-loopback.invalid/jev".into(); - invalid.judge.as_mut().ok_or("judge")?.credential = Secret("invalid-candidate-key".into()); + invalid.judge.as_mut().ok_or("judge")?.credential = + Some(Secret("invalid-candidate-key".into())); assert!( lifecycle .reinitialize(&loaded_name, serde_json::to_value(invalid)?) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 53edc4a5..d9cee565 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -2135,6 +2135,8 @@ fn configured_audit( fn configured_automatic( config: Option, approvals: Option<&Arc>, + connection: &Connection, + host: Option<&str>, ) -> BusResult>> { let Some(config) = config else { return Ok(None); @@ -2142,13 +2144,21 @@ fn configured_automatic( let engine = approvals.ok_or_else(|| tinybus::Error::failed("judge approval authority unavailable"))?; let previous = engine.previous_automatic().map_err(approval_failure)?; + let clock = Arc::new(tinysecurity::auto::SystemClock::default()); let candidate = if let Some(previous) = previous { previous.reconfigured(config) + } else if config.credential.is_some() { + tinysecurity::auto::Engine::new(config, clock) } else { - tinysecurity::auto::Engine::new( - config, - Arc::new(tinysecurity::auto::SystemClock::default()), - ) + // Without a configured credential only the init-bound trusted host may supply one. + let host = + host.ok_or_else(|| tinybus::Error::failed("judge credential host unavailable"))?; + let source = Arc::new(judge::HostCredential::new( + connection, + host, + config.timeout_ms, + )); + tinysecurity::auto::Engine::with_credential_source(config, source, clock) } .map_err(|_| tinybus::Error::failed("invalid judge initialization"))?; Ok(Some(Arc::new(candidate))) @@ -2413,7 +2423,12 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<() }, device.as_ref(), )?; - let judge = configured_automatic(judge_config, approvals.as_ref().map(|(_, e)| e))?; + let judge = configured_automatic( + judge_config, + approvals.as_ref().map(|(_, e)| e), + &connection, + requested_host.as_deref(), + )?; if let Some(active) = ACTIVE.get() { let generation = candidate.info().generation; { @@ -2555,6 +2570,8 @@ mod device_pairing; #[path = "adapter_crypto.rs"] mod crypto; +#[path = "adapter_judge.rs"] +mod judge; #[path = "adapter_secret.rs"] pub(crate) mod secret; diff --git a/crates/tinysecurity-module/src/adapter_approval_tests.rs b/crates/tinysecurity-module/src/adapter_approval_tests.rs index 782688cb..33621168 100644 --- a/crates/tinysecurity-module/src/adapter_approval_tests.rs +++ b/crates/tinysecurity-module/src/adapter_approval_tests.rs @@ -791,7 +791,7 @@ fn settings() -> (ApprovalConfig2, AuditConfig2) { fn judge_settings() -> JudgeConfig { JudgeConfig { - credential: Secret("fake-judge-key".into()), + credential: Some(Secret("fake-judge-key".into())), endpoint: "http://127.0.0.1:1/jev".into(), provider: JudgeProvider::TypeSafe, threshold_millis: 900, @@ -828,21 +828,34 @@ async fn explicit_judge_initialization_activates_the_native_engine() None, )?; let (_, approvals) = approvals.as_ref().ok_or("approvals")?; - let engine = configured_automatic(Some(judge.clone()), Some(approvals))?; + // Without a configured credential only the trusted host may supply one. + let mut hosted = judge.clone(); + hosted.credential = None; + assert!( + configured_automatic(Some(hosted.clone()), Some(approvals), &connection, None).is_err() + ); + let lazy = configured_automatic( + Some(hosted), + Some(approvals), + &connection, + Some(peer.as_str()), + )?; + assert!(lazy.is_some()); + let engine = configured_automatic(Some(judge.clone()), Some(approvals), &connection, None)?; assert!(engine.is_some()); approvals.replace_automatic(engine)?; let mut invalid = judge.clone(); invalid.endpoint = "http://not-loopback.invalid/jev".into(); - assert!(configured_automatic(Some(invalid), Some(approvals)).is_err()); + assert!(configured_automatic(Some(invalid), Some(approvals), &connection, None).is_err()); assert!(approvals.automatic()?.is_some()); approvals.replace_automatic(None)?; assert!(approvals.automatic()?.is_none()); assert!(approvals.previous_automatic()?.is_some()); - let restored = configured_automatic(Some(judge.clone()), Some(approvals))?; + let restored = configured_automatic(Some(judge.clone()), Some(approvals), &connection, None)?; assert!(restored.is_some()); approvals.replace_automatic(restored)?; - assert!(configured_automatic(Some(judge), None).is_err()); - assert!(configured_automatic(None, None)?.is_none()); + assert!(configured_automatic(Some(judge.clone()), None, &connection, None).is_err()); + assert!(configured_automatic(None, None, &connection, None)?.is_none()); assert!(!serde_json::to_string(&candidate.info())?.contains("fake-judge-key")); let redacted = candidate.redactor().redact(&RedactRequest { text: "fake-judge-key http://127.0.0.1:1/jev".into(), diff --git a/crates/tinysecurity-module/src/adapter_judge.rs b/crates/tinysecurity-module/src/adapter_judge.rs new file mode 100644 index 00000000..b833ee57 --- /dev/null +++ b/crates/tinysecurity-module/src/adapter_judge.rs @@ -0,0 +1,75 @@ +//! Judge credential served by the trusted host's `JudgeCredentialHost2`. +//! +//! Used only when the judge is configured without a credential. The module +//! asks after admission, on a judgement, never during load, so a host that +//! reads the credential from this module's own keyring cannot deadlock it. +use std::time::Duration; +use tinysecurity_bus::{ + JudgeCredentialReason2, JudgeCredentialRequest2, JudgeCredentialResponse2, Secret, + callback_names2, +}; + +pub(super) struct HostCredential { + connection: tinybus::Connection, + peer: String, + timeout_ms: u32, + /// Mock-bus unit tests lack confidential module provenance; production never sets it. + #[cfg(test)] + ordinary: bool, +} +impl std::fmt::Debug for HostCredential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("HostCredential([REDACTED])") + } +} +impl HostCredential { + /// The judge's own request deadline also bounds this callback. + pub(super) fn new(connection: &tinybus::Connection, peer: &str, timeout_ms: u32) -> Self { + Self { + connection: connection.clone(), + peer: peer.to_owned(), + timeout_ms, + #[cfg(test)] + ordinary: false, + } + } +} +#[async_trait::async_trait] +impl tinysecurity::auto::CredentialSource for HostCredential { + async fn credential(&self, rejected: bool) -> Option { + let proxy = self + .connection + .proxy( + self.peer.as_str(), + callback_names2::OBJECT_PATH, + callback_names2::JUDGE_CREDENTIAL_HOST, + ) + .ok()? + .with_timeout(Duration::from_millis(u64::from(self.timeout_ms))); + let args = (JudgeCredentialRequest2 { + reason: if rejected { + JudgeCredentialReason2::Rejected + } else { + JudgeCredentialReason2::Initial + }, + },); + // The credential is a secret: the host serves this member only as a + // confidential callback. + #[cfg(test)] + let confidential = !self.ordinary; + #[cfg(not(test))] + let confidential = true; + let reply: tinybus::Result = if confidential { + proxy + .call_confidential(callback_names2::CREDENTIAL, args) + .await + } else { + proxy.call(callback_names2::CREDENTIAL, args).await + }; + reply.ok()?.credential + } +} + +#[cfg(test)] +#[path = "adapter_judge_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-module/src/adapter_judge_tests.rs b/crates/tinysecurity-module/src/adapter_judge_tests.rs new file mode 100644 index 00000000..4d2db069 --- /dev/null +++ b/crates/tinysecurity-module/src/adapter_judge_tests.rs @@ -0,0 +1,94 @@ +use super::*; +use std::sync::{Arc, Mutex}; +use tinysecurity::auto::CredentialSource; + +#[derive(Clone, Default)] +struct Host { + reasons: Arc>>, + credential: Arc>>, +} +#[tinybus::interface(name = "ai.tinyhumans.tinysecurity.JudgeCredentialHost2")] +impl Host { + async fn credential( + &self, + request: JudgeCredentialRequest2, + ) -> tinybus::Result { + self.reasons + .lock() + .map_err(|_| tinybus::Error::failed("reasons"))? + .push(request.reason); + let credential = self + .credential + .lock() + .map_err(|_| tinybus::Error::failed("credential"))? + .clone() + .map(Secret); + std::future::ready(Ok(JudgeCredentialResponse2 { credential })).await + } +} + +/// A module connection asking `host`, which serves the callback when `serve`. +async fn fixture( + host: &Host, + serve: bool, +) -> Result<(HostCredential, tokio::task::AbortHandle), Box> { + use tinybus::{Connection, broker::Broker, transport::memory::MemoryBus}; + let bus = MemoryBus::new(); + let broker = Broker::new().spawn(bus.clone()).abort_handle(); + let hosting = Connection::connect(bus.connect().await?).await?; + let module = Connection::connect(bus.connect().await?).await?; + let host_peer = hosting.unique_name().ok_or("host")?; + let module_peer = module.unique_name().ok_or("module")?; + if serve { + hosting + .serve_at( + callback_names2::OBJECT_PATH.parse()?, + crate::callbacks::Authenticated::new( + host.clone(), + crate::callbacks::PeerBinding::new(Some(module_peer.as_str()))?, + ), + ) + .await?; + } + // Keep the host connection alive for the test body. + Box::leak(Box::new(hosting)); + let mut source = HostCredential::new(&module, host_peer.as_str(), 1000); + source.ordinary = true; + Ok((source, broker)) +} + +#[tokio::test] +async fn the_host_supplies_the_judge_credential_with_the_reason_it_is_asked() +-> Result<(), Box> { + let host = Host::default(); + *host.credential.lock().map_err(|_| "credential")? = Some("host-held-key".into()); + let (source, broker) = fixture(&host, true).await?; + assert_eq!( + source.credential(false).await.map(|secret| secret.0), + Some("host-held-key".to_owned()) + ); + assert!(source.credential(true).await.is_some()); + assert_eq!( + *host.reasons.lock().map_err(|_| "reasons")?, + [ + JudgeCredentialReason2::Initial, + JudgeCredentialReason2::Rejected + ] + ); + // The credential never appears in the source's own diagnostics. + assert_eq!(format!("{source:?}"), "HostCredential([REDACTED])"); + broker.abort(); + Ok(()) +} + +#[tokio::test] +async fn no_credential_or_no_serving_host_gives_none() -> Result<(), Box> { + let host = Host::default(); + let (source, broker) = fixture(&host, true).await?; + assert!(source.credential(false).await.is_none()); + broker.abort(); + let (unserved, broker) = fixture(&host, false).await?; + assert!(unserved.credential(false).await.is_none()); + broker.abort(); + Ok(()) +} diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index 11ce61ab..224a5ea5 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -411,7 +411,10 @@ fn validate(config: &ModuleConfig) -> Result<()> { fn registry(config: &ModuleConfig) -> Result { let mut registry = config.redactor.clone(); if let Some(judge) = &config.judge { - for secret in [&judge.credential, &crate::Secret(judge.endpoint.clone())] { + // A credential the host supplies after load is never in module text paths, + // so only a configured one is registered here. + let endpoint = crate::Secret(judge.endpoint.clone()); + for secret in judge.credential.iter().chain([&endpoint]) { if !registry.secrets.contains(secret) { registry.secrets.push(secret.clone()); } diff --git a/crates/tinysecurity-policy/tests/policy.rs b/crates/tinysecurity-policy/tests/policy.rs index c2b3ca6b..4d8db902 100644 --- a/crates/tinysecurity-policy/tests/policy.rs +++ b/crates/tinysecurity-policy/tests/policy.rs @@ -290,7 +290,7 @@ fn invalid_judge_and_policy_configuration_do_not_activate() let mut policy = Policy::new(ModuleConfig::default())?; let old = policy.info(); let judge = JudgeConfig { - credential: Secret("fixture-secret".into()), + credential: Some(Secret("fixture-secret".into())), endpoint: "http://not-loopback.invalid/judge".into(), provider: JudgeProvider::TypeSafe, exfiltrate_millis: 100, diff --git a/docs/specs/security-module.md b/docs/specs/security-module.md index f76a0e07..a8051907 100644 --- a/docs/specs/security-module.md +++ b/docs/specs/security-module.md @@ -54,7 +54,9 @@ discretionary policy is disabled. A claimed command class cannot authorize a call: the engine must compute it or conservatively strengthen it. Unknown commands are writes. Judge credentials are resolved by the host's backend credential resolver and -injected only at init/reinit. They never appear in per-call arguments, debug +injected at init/reinit, or, when the config omits one, served by the trusted +host's confidential `JudgeCredentialHost2` callback on first use and reused for at +most five minutes. They never appear in per-call arguments, debug formatting, logs, approval records or audit events. Debug output for any config carrying secrets is redacted. Init configuration serialization intentionally carries credentials to the trusted SDK loading boundary and must never be used