From b2f08e79aa9707f6f88066cce48d7fbd7742e5fd Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 4 Nov 2026 18:14:22 +0530 Subject: [PATCH 1/4] Call KeyringConsent2 as a confidential host callback The host serves KeyringConsent2 through a host callback binding, which refuses ordinary calls to a declared member. The module called it plainly, so every keyring operation failed consent as Unavailable and hosts fell back to their own secret paths. Call it with call_confidential, as the audit, approval store and prompt callbacks already do. Mock-bus unit tests keep ordinary calls through a test-only switch. keyring_native_contract loads the real module behind a binding and proves Set, Get and a refused Get. --- .../run-native-master-cipher-coverage.sh | 7 +- .../examples/keyring_native_contract.rs | 174 ++++++++++++++++++ .../tinysecurity-module/src/adapter_crypto.rs | 46 +++-- .../src/adapter_crypto_tests.rs | 7 +- 4 files changed, 215 insertions(+), 19 deletions(-) create mode 100644 crates/tinysecurity-module/examples/keyring_native_contract.rs diff --git a/.github/scripts/run-native-master-cipher-coverage.sh b/.github/scripts/run-native-master-cipher-coverage.sh index ef8132e8..2a0fbc8c 100755 --- a/.github/scripts/run-native-master-cipher-coverage.sh +++ b/.github/scripts/run-native-master-cipher-coverage.sh @@ -7,7 +7,7 @@ eval "$(cargo llvm-cov show-env --sh)" export CARGO_LLVM_COV_TARGET_DIR="$native_coverage_directory" export CARGO_LLVM_COV_BUILD_DIR="$native_coverage_directory" export LLVM_PROFILE_FILE="$native_coverage_directory/tinysecurity-%p-%28m.profraw" -cargo build --target-dir "$CARGO_LLVM_COV_BUILD_DIR" --locked -p tinysecurity-module --lib --example secret_cipher_native_contract --example device_native_contract --example audit_native_contract --all-features +cargo build --target-dir "$CARGO_LLVM_COV_BUILD_DIR" --locked -p tinysecurity-module --lib --example secret_cipher_native_contract --example device_native_contract --example audit_native_contract --example keyring_native_contract --all-features case "${RUNNER_OS:-$(uname -s)}" in Linux) library=libtinysecurity_module.so; executable=secret_cipher_native_contract ;; macOS|Darwin) library=libtinysecurity_module.dylib; executable=secret_cipher_native_contract ;; @@ -16,13 +16,15 @@ case "${RUNNER_OS:-$(uname -s)}" in esac audit_executable=audit_native_contract device_executable=device_native_contract -if [[ "${RUNNER_OS:-}" == Windows ]]; then device_executable+=.exe; audit_executable+=.exe; fi +keyring_executable=keyring_native_contract +if [[ "${RUNNER_OS:-}" == Windows ]]; then device_executable+=.exe; audit_executable+=.exe; keyring_executable+=.exe; fi build_directory="$CARGO_LLVM_COV_BUILD_DIR" if [[ "${RUNNER_OS:-}" == Windows ]]; then build_directory="$(cygpath -u "$build_directory")"; fi module="$build_directory/debug/$library" verifier="$build_directory/debug/examples/$executable" device_verifier="$build_directory/debug/examples/$device_executable" audit_verifier="$build_directory/debug/examples/$audit_executable" +keyring_verifier="$build_directory/debug/examples/$keyring_executable" if [[ "${RUNNER_OS:-}" == Windows ]]; then # The native loader/audit fixture retains the established restricted namespace. module="$(cygpath -u "$TINYSECURITY_AUDIT_TEST_ROOT")/$library" @@ -32,6 +34,7 @@ printf 'Native fixtures: repository working directory\n' "$verifier" "$module" "$device_verifier" "$module" "$audit_verifier" "$module" +"$keyring_verifier" "$module" "$verifier" "$module" 18 external_cwd="$(mktemp -d)" trap 'rmdir "$external_cwd"' EXIT diff --git a/crates/tinysecurity-module/examples/keyring_native_contract.rs b/crates/tinysecurity-module/examples/keyring_native_contract.rs new file mode 100644 index 00000000..531cd7a7 --- /dev/null +++ b/crates/tinysecurity-module/examples/keyring_native_contract.rs @@ -0,0 +1,174 @@ +//! Loaded native keyring access behind the host's confidential consent endpoint. +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tinybus::{ + Connection, + broker::Broker, + module::{HostCallbackEndpoint, ModuleHost}, + transport::memory::MemoryBus, +}; +use tinysecurity_bus::{ + CallerContext, CryptoConfig2, KeyringBackend2, KeyringConfig2, KeyringConsentOutcome2, + KeyringConsentRequest2, KeyringConsentResponse2, KeyringOperation2, KeyringRequest2, + KeyringResponse2, ModuleConfig, callback_names2, names, +}; + +#[derive(Clone)] +struct Consenter { + allow: Arc, + calls: Arc, +} +#[tinybus::interface(name = "ai.tinyhumans.tinysecurity.KeyringConsent2")] +impl Consenter { + async fn consent( + &self, + request: KeyringConsentRequest2, + ) -> tinybus::Result { + assert_eq!(request.name, "session-token"); + self.calls.fetch_add(1, Ordering::SeqCst); + std::future::ready(Ok(KeyringConsentResponse2 { + outcome: if self.allow.load(Ordering::SeqCst) { + KeyringConsentOutcome2::Allowed + } else { + KeyringConsentOutcome2::ConsentDenied + }, + })) + .await + } +} + +fn request(operation: KeyringOperation2) -> KeyringRequest2 { + KeyringRequest2 { + caller: CallerContext { + agent: "fixture-agent".into(), + call_id: "fixture-call".into(), + ..Default::default() + }, + purpose: "store the sign-in session".into(), + operation, + } +} + +async fn wait_owner(host: &Connection) -> Result> { + let peer = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(peer) = host.name_owner(names::INTERFACE).await? { + break tinybus::Result::Ok(peer); + } + tokio::task::yield_now().await; + } + }) + .await??; + Ok(peer) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let library = std::path::PathBuf::from( + std::env::args() + .nth(1) + .ok_or("usage: keyring_native_contract ")?, + ) + .canonicalize()?; + let name = library + .file_name() + .and_then(|n| n.to_str()) + .ok_or("native library name")?; + std::fs::write( + library + .parent() + .ok_or("module directory")? + .join("modules.toml"), + format!( + "\"{name}\" = \"{}\"\n", + tinybus::module::sha256_file(&library)? + ), + )?; + let store = tempfile::tempdir()?; + let bus = MemoryBus::new(); + let broker = Broker::new(); + let task = broker.spawn(bus.clone()); + let lifecycle = ModuleHost::new(broker); + let host = Connection::connect(bus.connect().await?).await?; + let path: tinybus::ObjectPath = callback_names2::OBJECT_PATH.parse()?; + // A real binding refuses ordinary calls to a declared callback member. + let binding = lifecycle + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: path.clone(), + interface: callback_names2::KEYRING_CONSENT.parse()?, + members: [callback_names2::CONSENT.parse()?].into(), + }]) + .await?; + let consenter = Consenter { + allow: Arc::new(AtomicBool::new(true)), + calls: Arc::default(), + }; + binding.serve_at(path, consenter.clone()).await?; + let configuration = ModuleConfig { + trusted_host: Some(binding.unique_name().to_string()), + crypto: Some(CryptoConfig2 { + max_payload_bytes: 4096, + operations_per_minute: 50, + keyring: Some(KeyringConfig2 { + service: "tinysecurity-keyring-fixture".into(), + backend: KeyringBackend2::EncryptedFile { + directory: store.path().canonicalize()?.to_string_lossy().into_owned(), + }, + consent_timeout_ms: 5000, + }), + pairing: None, + }), + ..ModuleConfig::default() + }; + lifecycle + .load_file_with_host_callbacks(&library, serde_json::to_value(&configuration)?, &binding) + .await?; + let module = wait_owner(&host).await?; + let proxy = binding + .proxy(module.as_str(), names::OBJECT_PATH, names::INTERFACE)? + .with_timeout(Duration::from_secs(10)); + + let _: KeyringResponse2 = proxy + .call_confidential( + names::methods::KEYRING_ACCESS2, + (request(KeyringOperation2::Set { + name: "session-token".into(), + value: b"fixture-session".to_vec(), + }),), + ) + .await?; + let read: KeyringResponse2 = proxy + .call_confidential( + names::methods::KEYRING_ACCESS2, + (request(KeyringOperation2::Get { + name: "session-token".into(), + }),), + ) + .await?; + assert!(read.found); + assert_eq!(read.bytes, b"fixture-session"); + assert_eq!(consenter.calls.load(Ordering::SeqCst), 2); + + // A human refusal reaches the module and still withholds the value. + consenter.allow.store(false, Ordering::SeqCst); + let denied: tinybus::Result = proxy + .call_confidential( + names::methods::KEYRING_ACCESS2, + (request(KeyringOperation2::Get { + name: "session-token".into(), + }),), + ) + .await; + assert!(denied.is_err()); + assert_eq!(consenter.calls.load(Ordering::SeqCst), 3); + + lifecycle.shutdown(Duration::from_secs(5)).await; + task.abort(); + println!("native keyring: confidential consent callback passed"); + Ok(()) +} diff --git a/crates/tinysecurity-module/src/adapter_crypto.rs b/crates/tinysecurity-module/src/adapter_crypto.rs index 919ad3dd..1f3fd2cb 100644 --- a/crates/tinysecurity-module/src/adapter_crypto.rs +++ b/crates/tinysecurity-module/src/adapter_crypto.rs @@ -58,6 +58,9 @@ struct Consent { connection: tinybus::Connection, peer: tinybus::BusName, timeout_ms: u32, + /// Mock-bus unit tests lack confidential module provenance; production never sets it. + #[cfg(test)] + ordinary: bool, } struct KeyringState { store: Arc, @@ -246,6 +249,8 @@ fn build(config: &mut CryptoConfig2, init: &Initialization<'_>) -> BusResult consent_error(KeyringConsentOutcome2::ConsentPending), - _ => consent_error(KeyringConsentOutcome2::Unavailable), - })?; + let args = (KeyringConsentRequest2 { + caller: request.caller.clone(), + purpose: request.purpose.clone(), + access, + name: name.to_owned(), + timeout_ms: keyring.consent.timeout_ms, + },); + // The host serves KeyringConsent2 as a confidential callback endpoint and + // refuses an ordinary call. + #[cfg(test)] + let reply: tinybus::Result = if keyring.consent.ordinary { + proxy.call(callback_names2::CONSENT, args).await + } else { + proxy + .call_confidential(callback_names2::CONSENT, args) + .await + }; + #[cfg(not(test))] + let reply: tinybus::Result = proxy + .call_confidential(callback_names2::CONSENT, args) + .await; + let reply = reply.map_err(|error| match error { + tinybus::Error::Timeout { .. } => consent_error(KeyringConsentOutcome2::ConsentPending), + _ => consent_error(KeyringConsentOutcome2::Unavailable), + })?; match reply.outcome { KeyringConsentOutcome2::Allowed => Ok(()), outcome => Err(consent_error(outcome)), diff --git a/crates/tinysecurity-module/src/adapter_crypto_tests.rs b/crates/tinysecurity-module/src/adapter_crypto_tests.rs index f846a3e1..e5281eeb 100644 --- a/crates/tinysecurity-module/src/adapter_crypto_tests.rs +++ b/crates/tinysecurity-module/src/adapter_crypto_tests.rs @@ -125,7 +125,12 @@ async fn fixture(allow: bool) -> Result<(Fixture, Consenter), Box Date: Wed, 4 Nov 2026 19:52:04 +0530 Subject: [PATCH 2/4] Run the keyring fixture from the external working directory The other native fixtures run a second time outside the repository; the keyring fixture now does too. --- .github/scripts/run-native-master-cipher-coverage.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/run-native-master-cipher-coverage.sh b/.github/scripts/run-native-master-cipher-coverage.sh index 2a0fbc8c..b45933b0 100755 --- a/.github/scripts/run-native-master-cipher-coverage.sh +++ b/.github/scripts/run-native-master-cipher-coverage.sh @@ -39,4 +39,4 @@ printf 'Native fixtures: repository working directory\n' external_cwd="$(mktemp -d)" trap 'rmdir "$external_cwd"' EXIT printf 'Native fixtures: external working directory\n' -(cd -- "$external_cwd" && "$verifier" "$module" && "$device_verifier" "$module" && "$audit_verifier" "$module" && "$verifier" "$module" 18) +(cd -- "$external_cwd" && "$verifier" "$module" && "$device_verifier" "$module" && "$audit_verifier" "$module" && "$keyring_verifier" "$module" && "$verifier" "$module" 18) From 75d9965b4973c568bd338769935343d9c6870c5a Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 4 Nov 2026 20:35:12 +0530 Subject: [PATCH 3/4] Compile both keyring consent call paths in every build The test-only branch held a confidential call that no build ran, so the native changed-line coverage gate counted it as uncovered. Choose the call with one flag, as the audit client does: native fixtures cover the confidential call and unit tests cover the ordinary one. --- crates/tinysecurity-module/src/adapter_crypto.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_crypto.rs b/crates/tinysecurity-module/src/adapter_crypto.rs index 1f3fd2cb..87230965 100644 --- a/crates/tinysecurity-module/src/adapter_crypto.rs +++ b/crates/tinysecurity-module/src/adapter_crypto.rs @@ -494,17 +494,16 @@ async fn consent( // The host serves KeyringConsent2 as a confidential callback endpoint and // refuses an ordinary call. #[cfg(test)] - let reply: tinybus::Result = if keyring.consent.ordinary { - proxy.call(callback_names2::CONSENT, args).await - } else { + let confidential = !keyring.consent.ordinary; + #[cfg(not(test))] + let confidential = true; + let reply: tinybus::Result = if confidential { proxy .call_confidential(callback_names2::CONSENT, args) .await + } else { + proxy.call(callback_names2::CONSENT, args).await }; - #[cfg(not(test))] - let reply: tinybus::Result = proxy - .call_confidential(callback_names2::CONSENT, args) - .await; let reply = reply.map_err(|error| match error { tinybus::Error::Timeout { .. } => consent_error(KeyringConsentOutcome2::ConsentPending), _ => consent_error(KeyringConsentOutcome2::Unavailable), From 57d0b302e8f0d91e25115de6694f146336a5bd0f Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 4 Nov 2026 20:55:33 +0530 Subject: [PATCH 4/4] Test keyring consent with no serving host Consent fails closed as Unavailable when nothing serves KeyringConsent2. The test also runs the confidential call in the unit build. --- .../src/adapter_crypto_tests.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/tinysecurity-module/src/adapter_crypto_tests.rs b/crates/tinysecurity-module/src/adapter_crypto_tests.rs index e5281eeb..4f4306e1 100644 --- a/crates/tinysecurity-module/src/adapter_crypto_tests.rs +++ b/crates/tinysecurity-module/src/adapter_crypto_tests.rs @@ -837,3 +837,32 @@ async fn stored_values_beyond_the_bound_and_oversized_passwords_are_refused() } Ok(()) } + +#[tokio::test] +async fn keyring_consent_without_a_serving_host_is_unavailable() +-> Result<(), Box> { + // Nothing serves KeyringConsent2, so consent fails closed before any backend access. + let clock: Arc = Arc::new(StepClock::default()); + let dir = tempfile::tempdir()?; + let (state, _connection, _init) = + standalone(config(&dir.path().canonicalize()?), clock).await?; + let mut service = super::super::service_for( + Arc::new(std::sync::RwLock::new(tinysecurity::Policy::new( + tinysecurity_bus::ModuleConfig::default(), + )?)), + None, + ); + service.crypto = Some(state); + let refusal = service + .keyring_access(keyring_request(KeyringOperation2::Get { + name: "user:token".into(), + })) + .await + .err() + .ok_or("expected refusal")?; + assert_eq!( + refusal.wire_name(), + "ai.tinyhumans.tinysecurity.Error.Unavailable" + ); + Ok(()) +}