Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/scripts/run-native-master-cipher-coverage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ;;
Expand All @@ -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"
Expand All @@ -32,8 +34,9 @@ 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
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)
174 changes: 174 additions & 0 deletions crates/tinysecurity-module/examples/keyring_native_contract.rs
Original file line number Diff line number Diff line change
@@ -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<AtomicBool>,
calls: Arc<AtomicUsize>,
}
#[tinybus::interface(name = "ai.tinyhumans.tinysecurity.KeyringConsent2")]
impl Consenter {
async fn consent(
&self,
request: KeyringConsentRequest2,
) -> tinybus::Result<KeyringConsentResponse2> {
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<tinybus::BusName, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let library = std::path::PathBuf::from(
std::env::args()
.nth(1)
.ok_or("usage: keyring_native_contract <native library>")?,
)
.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<KeyringResponse2> = 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(())
}
45 changes: 29 additions & 16 deletions crates/tinysecurity-module/src/adapter_crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn SecretStore>,
Expand Down Expand Up @@ -246,6 +249,8 @@ fn build(config: &mut CryptoConfig2, init: &Initialization<'_>) -> BusResult<Bui
connection: init.connection.clone(),
peer: bus_peer.clone(),
timeout_ms: keyring.consent_timeout_ms,
#[cfg(test)]
ordinary: false,
},
})
})
Expand Down Expand Up @@ -479,22 +484,30 @@ async fn consent(
callback_names2::KEYRING_CONSENT,
)?
.with_timeout(Duration::from_millis(u64::from(keyring.consent.timeout_ms)));
let reply: KeyringConsentResponse2 = proxy
.call(
callback_names2::CONSENT,
(KeyringConsentRequest2 {
caller: request.caller.clone(),
purpose: request.purpose.clone(),
access,
name: name.to_owned(),
timeout_ms: keyring.consent.timeout_ms,
},),
)
.await
.map_err(|error| match error {
tinybus::Error::Timeout { .. } => 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 confidential = !keyring.consent.ordinary;
#[cfg(not(test))]
let confidential = true;
let reply: tinybus::Result<KeyringConsentResponse2> = if confidential {
proxy
.call_confidential(callback_names2::CONSENT, args)
.await
} else {
proxy.call(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)),
Expand Down
36 changes: 35 additions & 1 deletion crates/tinysecurity-module/src/adapter_crypto_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,12 @@ async fn fixture(allow: bool) -> Result<(Fixture, Consenter), Box<dyn std::error
std::fs::create_dir_all(&dir)?;
let mut init = initialization(&module, host_peer.as_str());
init.peer = Some(Box::leak(host_peer.as_str().to_owned().into_boxed_str()));
let state = configure(Some(config(&dir)), None, &init)?.ok_or("state")?;
let mut state = configure(Some(config(&dir)), None, &init)?.ok_or("state")?;
Arc::get_mut(&mut state)
.and_then(|state| state.keyring.as_mut())
.ok_or("keyring")?
.consent
.ordinary = true;
let mut service = super::super::service_for(
Arc::new(std::sync::RwLock::new(tinysecurity::Policy::new(
tinysecurity_bus::ModuleConfig::default(),
Expand Down Expand Up @@ -832,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<dyn std::error::Error>> {
// Nothing serves KeyringConsent2, so consent fails closed before any backend access.
let clock: Arc<dyn tinysecurity::approval::Clock> = 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(())
}
Loading