From c6a3b125740346f15231b70b729d4dacb1e723d9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:07:05 +0300 Subject: [PATCH 1/8] feat(crypto): add confidential tunnel frames --- MODULE.md | 9 +- README.md | 3 + crates/tinysecurity-bus/src/device.rs | 7 + crates/tinysecurity-bus/src/device_frame.rs | 61 +++++ .../src/device_frame_tests.rs | 46 ++++ crates/tinysecurity-bus/src/device_tests.rs | 19 +- crates/tinysecurity-bus/src/lib.rs | 4 +- crates/tinysecurity-bus/src/names.rs | 3 + crates/tinysecurity-bus/tests/scoped_wire.rs | 7 +- crates/tinysecurity-bus/tests/wire.rs | 2 +- .../tinysecurity-crypto/src/device_handles.rs | 97 +++++++- .../src/device_handles_tests.rs | 210 +++++++++++++++++- .../device_native_contract/fixture.rs | 1 + crates/tinysecurity-module/src/adapter.rs | 9 +- .../src/adapter_crypto_tests.rs | 1 + .../tinysecurity-module/src/adapter_device.rs | 58 ++++- .../src/adapter_device_tests.rs | 115 ++++++++++ 17 files changed, 638 insertions(+), 14 deletions(-) create mode 100644 crates/tinysecurity-bus/src/device_frame.rs create mode 100644 crates/tinysecurity-bus/src/device_frame_tests.rs diff --git a/MODULE.md b/MODULE.md index 32c86bcf..719ab592 100644 --- a/MODULE.md +++ b/MODULE.md @@ -142,7 +142,7 @@ frames and handles do not reach approval/audit payloads, errors, Debug or `PolicyInfo`. Explicit init bounds cover registrations, handles, concurrent reservations, -per-scope rate, TTL, message bytes and retained bootstrap nonces. Reinit may +per-scope control-operation rate, per-tunnel frame rate, TTL, message bytes and retained bootstrap nonces. `frames_per_minute` defaults to 12000 (200 frames/second) and is independently configurable up to 60000; older device configs receive that default. Reinit may change bounds without reimporting credentials or changing immutable subjects. It invalidates all old handles; successful new-generation receipts issue new handles. Revoke removes dependent tunnels; scoped Cancel fences pending work. @@ -151,6 +151,13 @@ closed at capacity. Clock rollback/unavailability faults device authority for that process. Shutdown closes owned credentials even when an SDK observer retains the connection. See the [owned cleanup and replay limits](crates/tinysecurity-crypto/README.md#scoped-device-handles). +Contract 1.23 adds confidential `DeviceFrame2` for streaming v2 frames. It +accepts only a live tunnel handle returned by `ExecuteDevice2`; it cannot issue +or extend a handle and checks scope, generation, TTL, revocation, per-tunnel +frame rate and message bounds. Frame calls reuse the capability granted by the approved +tunnel operation, so they need no new human receipt. Handshake and +acknowledgement operations remain on the approved Device2 path. + `device_native_contract` loads the hashed shared library and exercises actual confidential dispatch, verified approval/audit callback continuity, v2/v3/v4, replay/revoke, valid generation-two receipts, old-handle denial and SDK shutdown. diff --git a/README.md b/README.md index a7b8dc16..1bc48c56 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ TinySecurity supplies security policy through a native TinyBus module. Hosts compile the transport-free `tinysecurity-bus` contract and load the isolated `tinysecurity-module` artifact. The bootstrap implements `Evaluate`, batched `Check`, `PolicyInfo`, validated SDK configuration and policy generations. +Contract 1.23 adds confidential per-frame `DeviceFrame2` calls over the live +opaque tunnel capability issued by approved `ExecuteDevice2` work; see the +[device contract](MODULE.md#confidential-device-handles). Contract 1.1 also supplies immutable native [filesystem scopes](docs/specs/immutable-path-scopes.md) through `RegisterPathPolicy`, `ValidatePath`, `ValidateParent` and `CheckPath`. Contract 1.3 adds native `Redact`, `ScanPrompt` and `ScanToolDefinition`, with a diff --git a/crates/tinysecurity-bus/src/device.rs b/crates/tinysecurity-bus/src/device.rs index d3ed8d59..53b59800 100644 --- a/crates/tinysecurity-bus/src/device.rs +++ b/crates/tinysecurity-bus/src/device.rs @@ -13,6 +13,10 @@ pub struct DeviceConfig2 { pub max_in_flight: u32, /// Operations per scope per monotonic minute, in 1..=4096. pub operations_per_minute: u32, + /// Frames per live tunnel per monotonic minute; defaults to 12000 (200 frames/second). + /// This budget is independent of control-plane `operations_per_minute`. + #[serde(default = "default_frames_per_minute")] + pub frames_per_minute: u32, /// Maximum handle and operation lifetime, in 1..=300000 milliseconds. pub max_ttl_ms: u32, /// Plaintext bound, in 1..=1048576 bytes. @@ -20,6 +24,9 @@ pub struct DeviceConfig2 { /// Authenticated bootstrap nonces retained across the registry, in 1..=4096. pub max_bootstrap_nonces: u32, } +fn default_frames_per_minute() -> u32 { + 12_000 +} /// Init-only static credential and immutable approval registration reference. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/crates/tinysecurity-bus/src/device_frame.rs b/crates/tinysecurity-bus/src/device_frame.rs new file mode 100644 index 00000000..67bf7dc4 --- /dev/null +++ b/crates/tinysecurity-bus/src/device_frame.rs @@ -0,0 +1,61 @@ +//! Confidential per-frame operations over a previously approved native tunnel. + +use serde::{Deserialize, Serialize}; + +/// Seal or open one bounded frame through a tunnel handle returned by approved device work. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum DeviceFrameOperation2 { + /// Encrypt plaintext as a version-two tunnel frame. + Seal { + /// Confidential plaintext, bounded by the configured device limit. + plaintext: Vec, + }, + /// Authenticate and decrypt a version-two tunnel frame. + Open { + /// Confidential frame, bounded by the configured device limit. + frame: Vec, + }, +} + +/// Confidential frame request using an opaque tunnel handle from `ExecuteDevice2`. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeviceFrameRequest2 { + /// Existing registered device scope that owns the handle. + pub scope_id: String, + /// Opaque tunnel capability returned by an approved `DeviceOperation2::Tunnel`. + pub handle: String, + /// Exact host execution instance, bounded to 128 bytes. + pub call_id: String, + /// One seal or open operation; no keys or caller identity are accepted. + pub operation: DeviceFrameOperation2, +} + +/// Confidential frame result; opening returns plaintext only over the trusted bus link. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeviceFrameResponse2 { + /// Sealed frame or authenticated plaintext. + pub bytes: Vec, +} + +impl std::fmt::Debug for DeviceFrameOperation2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("DeviceFrameOperation2([REDACTED])") + } +} +impl std::fmt::Debug for DeviceFrameRequest2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("DeviceFrameRequest2([REDACTED])") + } +} +impl std::fmt::Debug for DeviceFrameResponse2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("DeviceFrameResponse2([REDACTED])") + } +} + +#[cfg(test)] +#[path = "device_frame_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs new file mode 100644 index 00000000..5dd90e71 --- /dev/null +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -0,0 +1,46 @@ +use super::*; + +#[test] +fn frame_requests_and_responses_pin_the_confidential_wire_shape() +-> std::result::Result<(), Box> { + let request = DeviceFrameRequest2 { + scope_id: "registered-device".into(), + handle: "opaque-tunnel-handle".into(), + call_id: "socket-frame-17".into(), + operation: DeviceFrameOperation2::Seal { + plaintext: b"device payload".to_vec(), + }, + }; + let wire = serde_json::json!({ + "scope_id":"registered-device", + "handle":"opaque-tunnel-handle", + "call_id":"socket-frame-17", + "operation":{"kind":"seal","plaintext":[100,101,118,105,99,101,32,112,97,121,108,111,97,100]} + }); + assert_eq!(serde_json::to_value(&request)?, wire); + assert_eq!( + serde_json::from_value::(wire)?, + request + ); + assert_eq!(format!("{request:?}"), "DeviceFrameRequest2([REDACTED])"); + + let mut forged = serde_json::to_value(&request)?; + forged["agent"] = serde_json::json!("model-controlled"); + assert!(serde_json::from_value::(forged).is_err()); + + let response = DeviceFrameResponse2 { + bytes: vec![0, 128, 255], + }; + assert_eq!( + serde_json::to_value(&response)?, + serde_json::json!({"bytes":[0,128,255]}) + ); + assert_eq!(format!("{response:?}"), "DeviceFrameResponse2([REDACTED])"); + Ok(()) +} + +#[test] +fn frame_member_name_is_stable_and_contract_version_is_bumped() { + assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2"); + assert_eq!(crate::CONTRACT_VERSION, (1, 23)); +} diff --git a/crates/tinysecurity-bus/src/device_tests.rs b/crates/tinysecurity-bus/src/device_tests.rs index 33b68188..f4c8a2dc 100644 --- a/crates/tinysecurity-bus/src/device_tests.rs +++ b/crates/tinysecurity-bus/src/device_tests.rs @@ -205,13 +205,14 @@ fn device_configuration_and_scopes_pin_bounds_origin_and_optional_secret() max_handles: 101, max_in_flight: 7, operations_per_minute: 103, + frames_per_minute: 104, max_ttl_ms: 104, max_message_bytes: 105, max_bootstrap_nonces: 106, }, json!({ "scopes":[scope_wire],"max_handles":101,"max_in_flight":7, - "operations_per_minute":103,"max_ttl_ms":104,"max_message_bytes":105, + "operations_per_minute":103,"frames_per_minute":104,"max_ttl_ms":104,"max_message_bytes":105, "max_bootstrap_nonces":106, }), )?; @@ -219,6 +220,22 @@ fn device_configuration_and_scopes_pin_bounds_origin_and_optional_secret() Ok(()) } +#[test] +fn older_device_config_defaults_the_independent_frame_budget() +-> std::result::Result<(), Box> { + let config: DeviceConfig2 = serde_json::from_value(serde_json::json!({ + "scopes": [{"scope_id":"registered-scope","origin":"operator","static_secret":null}], + "max_handles": 8, + "max_in_flight": 2, + "operations_per_minute": 600, + "max_ttl_ms": 300_000, + "max_message_bytes": 1024, + "max_bootstrap_nonces": 8 + }))?; + assert_eq!(config.frames_per_minute, 12_000); + Ok(()) +} + #[test] fn device_results_pin_optional_handle_public_key_and_byte_arrays() -> std::result::Result<(), Box> { diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 5717e46d..64ad8dab 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -10,6 +10,8 @@ mod secret; pub use secret::*; mod device; pub use device::*; +mod device_frame; +pub use device_frame::*; mod sandbox; pub use sandbox::*; mod sandbox_scopes; @@ -57,7 +59,7 @@ pub use redact::RedactorConfig; pub use scoped_policy::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 22); +pub const CONTRACT_VERSION: (u32, u32) = (1, 23); /// Whether a remote module's advertised version supplies this host's vocabulary. /// /// `version` is the provider version received by the host, not a host requirement diff --git a/crates/tinysecurity-bus/src/names.rs b/crates/tinysecurity-bus/src/names.rs index f664b323..e35c2f0b 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -23,6 +23,8 @@ pub mod methods { pub const EVALUATE_DEVICE2: &str = "EvaluateDevice2"; /// Execute exact reviewed confidential work using scoped opaque handles. pub const EXECUTE_DEVICE2: &str = "ExecuteDevice2"; + /// Seal or open a frame through a live tunnel handle created by approved device work. + pub const DEVICE_FRAME2: &str = "DeviceFrame2"; /// Trusted legacy history migration without broad grant revival. pub const IMPORT_APPROVAL_HISTORY2: &str = "ImportApprovalHistory2"; /// Durable exact approval evaluation. @@ -169,6 +171,7 @@ pub const METHODS: &[&str] = &[ methods::PAIRING_CONTROL2, methods::EVALUATE_DEVICE2, methods::EXECUTE_DEVICE2, + methods::DEVICE_FRAME2, methods::PLAN, methods::EVALUATE_SANDBOX_APPROVAL2, methods::CONSUME_SANDBOX_PLAN2, diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index 7dfecb2a..aaf4128e 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -74,6 +74,7 @@ fn new_members_are_advertised_and_named_by_contract_constants() { names::methods::SCOPED_POLICY_INFO, names::methods::EVALUATE_SCOPED_AUDITED, names::methods::CHECK_SCOPED_AUDITED, + names::methods::DEVICE_FRAME2, ] { assert!(names::METHODS.contains(&name), "{name}"); } @@ -82,10 +83,10 @@ fn new_members_are_advertised_and_named_by_contract_constants() { #[test] fn contract_bump_is_additive_for_older_hosts_and_modules() { - assert_eq!(CONTRACT_VERSION, (1, 22)); - // A host built against 1.22 accepts this module and newer minors, not a 1.21 module. - assert!(is_compatible((1, 22))); + assert_eq!(CONTRACT_VERSION, (1, 23)); + // A host built against 1.23 accepts this module and newer minors, not a 1.22 module. assert!(is_compatible((1, 23))); + assert!(!is_compatible((1, 22))); assert!(!is_compatible((1, 21))); assert!(!is_compatible((1, 20))); assert!(!is_compatible((1, 19))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 22b26eae..c326c4dd 100644 --- a/crates/tinysecurity-bus/tests/wire.rs +++ b/crates/tinysecurity-bus/tests/wire.rs @@ -343,7 +343,7 @@ fn redactor_init_is_additive_and_secrets_never_enter_public_info() serde_json::to_value(&config)?["redactor"]["secrets"][0], "literal-private" ); - assert_eq!(CONTRACT_VERSION, (1, 22)); + assert_eq!(CONTRACT_VERSION, (1, 23)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), diff --git a/crates/tinysecurity-crypto/src/device_handles.rs b/crates/tinysecurity-crypto/src/device_handles.rs index f5fed78a..62f1ffd9 100644 --- a/crates/tinysecurity-crypto/src/device_handles.rs +++ b/crates/tinysecurity-crypto/src/device_handles.rs @@ -10,8 +10,9 @@ use std::{ sync::{Arc, Mutex, MutexGuard}, }; use tinysecurity_bus::{ - ApprovalScope2, DeviceConfig2, DeviceKeyPurpose2, DeviceOperation2, DeviceRequest2, - DeviceResponse2, DeviceRole2, + ApprovalScope2, DeviceConfig2, DeviceFrameOperation2, DeviceFrameRequest2, + DeviceFrameResponse2, DeviceKeyPurpose2, DeviceOperation2, DeviceRequest2, DeviceResponse2, + DeviceRole2, }; use zeroize::Zeroizing; /// Injected epoch and monotonic milliseconds; neither value comes from an invocation. @@ -45,6 +46,7 @@ struct Entry { epoch: u64, expires_wall: u64, expires_monotonic: u64, + frame_rate: RateCounter, native: Native, } struct State { @@ -293,6 +295,95 @@ impl Registry { check_operation(operation, state.limits.max_message_bytes)?; state.execute(work, operation) } + /// Seal or open a frame through a tunnel handle created by an approved device operation. + /// + /// The opaque tunnel handle is the capability. This fast path accepts no key material and + /// cannot create or extend a session; it checks the registered scope, live generation, + /// expiry, revocation epoch, message bound and the same per-scope operation budget as the + /// approval path. A revoked, expired or reconfigured handle is unusable immediately. + /// + /// # Errors + /// Rejects an unknown or stale tunnel, malformed frame, replay, oversized payload or limit. + pub fn execute_frame( + &self, + request: &DeviceFrameRequest2, + generation: u64, + now: Time, + ) -> Result { + let mut state = self.lock()?; + state.check_time(now)?; + state.prune(now); + if generation != state.generation + || request.scope_id.is_empty() + || request.scope_id.len() > 128 + || request.handle.is_empty() + || request.handle.len() > 64 + || request.call_id.is_empty() + || request.call_id.len() > 128 + { + return Err(Error::DeviceDenied); + } + let scope = state + .scopes + .get(&request.scope_id) + .ok_or(Error::DeviceDenied)?; + let entry = state + .handles + .get(&request.handle) + .filter(|entry| entry.scope == request.scope_id && entry.epoch == scope.epoch) + .ok_or(Error::DeviceDenied)?; + if !matches!(entry.native, Native::Tunnel(..)) { + return Err(Error::DeviceDenied); + } + match &request.operation { + DeviceFrameOperation2::Seal { plaintext } => { + if plaintext.len() > state.limits.max_message_bytes as usize { + return Err(Error::Limit); + } + } + DeviceFrameOperation2::Open { frame } => { + if frame.len() > state.limits.max_message_bytes as usize + 41 { + return Err(Error::Limit); + } + } + } + let max_in_flight = state.limits.max_in_flight; + let max_rate = state.limits.frames_per_minute; + let State { + budget, handles, .. + } = &mut *state; + let entry = handles + .get_mut(&request.handle) + .filter(|entry| entry.scope == request.scope_id) + .ok_or(Error::DeviceDenied)?; + budget + .reserve( + &mut entry.frame_rate, + now.monotonic, + max_in_flight, + max_rate, + ) + .map_err(|error| match error { + BudgetError::Capacity => Error::DeviceCapacity, + BudgetError::Rate => Error::DeviceRate, + })?; + let result = (|| { + let entry = handles + .get_mut(&request.handle) + .filter(|entry| entry.scope == request.scope_id) + .ok_or(Error::DeviceDenied)?; + let Native::Tunnel(tunnel, _, _) = &mut entry.native else { + return Err(Error::DeviceDenied); + }; + let bytes = match &request.operation { + DeviceFrameOperation2::Seal { plaintext } => tunnel.seal(plaintext)?, + DeviceFrameOperation2::Open { frame } => tunnel.open(frame)?.to_vec(), + }; + Ok(DeviceFrameResponse2 { bytes }) + })(); + budget.release(); + result + } /// Close native authority and drop all owned credentials even if observers remain. /// Cleanup recovers a poisoned lock only to destroy state; it never restores authority. pub fn close(&self) { @@ -489,6 +580,7 @@ impl State { epoch: work.epoch, expires_wall: work.expires_wall, expires_monotonic: work.expires_monotonic, + frame_rate: RateCounter::new(self.time.monotonic), native, }, ); @@ -680,6 +772,7 @@ fn validate_config(config: &DeviceConfig2) -> Result<()> { || !(1..=4096).contains(&config.max_handles) || !(1..=32).contains(&config.max_in_flight) || !(1..=4096).contains(&config.operations_per_minute) + || !(1..=60_000).contains(&config.frames_per_minute) || !(1..=300_000).contains(&config.max_ttl_ms) || !(1..=1_048_576).contains(&config.max_message_bytes) || !(1..=4096).contains(&config.max_bootstrap_nonces) diff --git a/crates/tinysecurity-crypto/src/device_handles_tests.rs b/crates/tinysecurity-crypto/src/device_handles_tests.rs index 2dfe12df..ff9ec992 100644 --- a/crates/tinysecurity-crypto/src/device_handles_tests.rs +++ b/crates/tinysecurity-crypto/src/device_handles_tests.rs @@ -39,6 +39,7 @@ fn config() -> DeviceConfig2 { max_handles: 16, max_in_flight: 2, operations_per_minute: 100, + frames_per_minute: 12_000, max_ttl_ms: 100, max_message_bytes: 1000, max_bootstrap_nonces: 2, @@ -593,6 +594,209 @@ fn both_native_tunnel_roles_preserve_direction_replay_and_reflection() -> TestRe ); Ok(()) } + +#[test] +fn confidential_frames_reuse_only_a_live_approved_tunnel_capability() -> TestResult { + let reg = registry()?; + let (static_a, public_a) = key(®, 'a', DeviceKeyPurpose2::Static)?; + let (static_b, public_b) = key(®, 'a', DeviceKeyPurpose2::Static)?; + let (eph_a, epublic_a) = key(®, 'a', DeviceKeyPurpose2::Ephemeral)?; + let (eph_b, epublic_b) = key(®, 'a', DeviceKeyPurpose2::Ephemeral)?; + let client = tunnel( + ®, + &static_a, + &eph_a, + &public_b, + &epublic_b, + DeviceRole2::Client, + )?; + let server = tunnel( + ®, + &static_b, + &eph_b, + &public_a, + &epublic_a, + DeviceRole2::Server, + )?; + let sealed = reg.execute_frame( + &DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: client, + call_id: "socket-send-1".into(), + operation: DeviceFrameOperation2::Seal { + plaintext: b"confidential socket frame".to_vec(), + }, + }, + 1, + time(0), + )?; + let opened = reg.execute_frame( + &DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: server.clone(), + call_id: "socket-receive-1".into(), + operation: DeviceFrameOperation2::Open { + frame: sealed.bytes.clone(), + }, + }, + 1, + time(0), + )?; + assert_eq!(opened.bytes, b"confidential socket frame"); + assert!(matches!( + reg.execute_frame( + &DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: server.clone(), + call_id: "socket-receive-replay".into(), + operation: DeviceFrameOperation2::Open { + frame: sealed.bytes.clone(), + }, + }, + 1, + time(0), + ), + Err(Error::Replay) + )); + assert!(matches!( + reg.execute_frame( + &DeviceFrameRequest2 { + scope_id: "b".repeat(32), + handle: server.clone(), + call_id: "wrong-device".into(), + operation: DeviceFrameOperation2::Open { + frame: sealed.bytes + }, + }, + 1, + time(0), + ), + Err(Error::DeviceDenied) + )); + run( + ®, + 'a', + DeviceOperation2::Revoke { + handle: server.clone(), + }, + )?; + assert!( + reg.execute_frame( + &DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: server, + call_id: "after-revoke".into(), + operation: DeviceFrameOperation2::Seal { plaintext: vec![] }, + }, + 1, + time(0), + ) + .is_err() + ); + Ok(()) +} + +#[test] +fn confidential_frames_enforce_size_limits_expiry_and_replay() -> TestResult { + let mut cfg = config(); + cfg.max_message_bytes = 4; + let reg = Registry::new( + cfg, + &[registration(&"a".repeat(32)), registration(&"b".repeat(32))], + ":1.1", + 1, + time(0), + )?; + let (static_handle, _) = key(®, 'a', DeviceKeyPurpose2::Static)?; + let (ephemeral, _) = key(®, 'a', DeviceKeyPurpose2::Ephemeral)?; + let external = KeyPair::from_secret(Zeroizing::new([9; 32]))?; + let remote_ephemeral = KeyPair::from_secret(Zeroizing::new([10; 32]))?; + let tunnel_handle = tunnel( + ®, + &static_handle, + &ephemeral, + &external.public_key().encode(), + &remote_ephemeral.public_key().encode(), + DeviceRole2::Client, + )?; + let too_large = reg.execute_frame( + &DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: tunnel_handle.clone(), + call_id: "oversized".into(), + operation: DeviceFrameOperation2::Seal { + plaintext: b"12345".to_vec(), + }, + }, + 1, + time(0), + ); + assert!(matches!(too_large, Err(Error::Limit))); + let expired_request = request( + 'a', + DeviceOperation2::Seal { + handle: tunnel_handle.clone(), + plaintext: vec![], + }, + ); + assert!( + reg.execute_frame( + &DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: tunnel_handle, + call_id: expired_request.call_id, + operation: DeviceFrameOperation2::Seal { plaintext: vec![] }, + }, + 1, + time(101), + ) + .is_err() + ); + Ok(()) +} + +#[test] +fn frame_budget_is_per_tunnel_and_independent_of_control_operations() -> TestResult { + let mut cfg = config(); + cfg.frames_per_minute = 1; + cfg.operations_per_minute = 100; + let reg = Registry::new( + cfg, + &[registration(&"a".repeat(32)), registration(&"b".repeat(32))], + ":1.1", + 1, + time(0), + )?; + let (static_handle, _) = key(®, 'a', DeviceKeyPurpose2::Static)?; + let (ephemeral, _) = key(®, 'a', DeviceKeyPurpose2::Ephemeral)?; + let external = KeyPair::from_secret(Zeroizing::new([19; 32]))?; + let remote_ephemeral = KeyPair::from_secret(Zeroizing::new([20; 32]))?; + let handle = tunnel( + ®, + &static_handle, + &ephemeral, + &external.public_key().encode(), + &remote_ephemeral.public_key().encode(), + DeviceRole2::Client, + )?; + let frame_request = |call_id: &str| DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: handle.clone(), + call_id: call_id.into(), + operation: DeviceFrameOperation2::Seal { + plaintext: b"x".to_vec(), + }, + }; + reg.execute_frame(&frame_request("frame-1"), 1, time(0))?; + assert!(matches!( + reg.execute_frame(&frame_request("frame-2"), 1, time(0)), + Err(Error::DeviceRate) + )); + // The independent control plane remains available after a frame budget is exhausted. + key(®, 'a', DeviceKeyPurpose2::Static)?; + Ok(()) +} + #[test] fn invalid_initialization_inputs_and_every_operation_shape_fail_closed() -> TestResult { let regs = [registration(&"a".repeat(32)), registration(&"b".repeat(32))]; @@ -605,7 +809,7 @@ fn invalid_initialization_inputs_and_every_operation_shape_fail_closed() -> Test let mut duplicate = config(); duplicate.scopes.push(duplicate.scopes[0].clone()); assert!(init(duplicate, ®s, ":1.1", 1).is_err()); - for change in 0..7 { + for change in 0..9 { let mut cfg = config(); match change { 0 => cfg.scopes.clear(), @@ -614,7 +818,9 @@ fn invalid_initialization_inputs_and_every_operation_shape_fail_closed() -> Test 3 => cfg.operations_per_minute = 0, 4 => cfg.max_ttl_ms = 300_001, 5 => cfg.max_message_bytes = 1_048_577, - _ => cfg.max_bootstrap_nonces = 0, + 6 => cfg.max_bootstrap_nonces = 0, + 7 => cfg.frames_per_minute = 0, + _ => cfg.frames_per_minute = 60_001, } assert!(init(cfg, ®s, ":1.1", 1).is_err()); } diff --git a/crates/tinysecurity-module/examples/device_native_contract/fixture.rs b/crates/tinysecurity-module/examples/device_native_contract/fixture.rs index 2aaf4c0e..923268be 100644 --- a/crates/tinysecurity-module/examples/device_native_contract/fixture.rs +++ b/crates/tinysecurity-module/examples/device_native_contract/fixture.rs @@ -42,6 +42,7 @@ pub(crate) fn device_config() -> DeviceConfig2 { max_handles: 32, max_in_flight: 4, operations_per_minute: 4096, + frames_per_minute: 12_000, max_ttl_ms: 300_000, max_message_bytes: 1024, max_bootstrap_nonces: 64, diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 5a7e85ae..662e2a98 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -308,6 +308,13 @@ impl PolicyService { ) -> BusResult { self.device_execute(request).await } + #[tinybus(confidential)] + async fn device_frame2( + &self, + request: tinysecurity_bus::DeviceFrameRequest2, + ) -> BusResult { + std::future::ready(self.device_frame(request)).await + } async fn plan( &self, _request: tinysecurity_bus::SandboxPlanRequest, @@ -1944,7 +1951,7 @@ tinybus_module::module_export! { config = ModuleConfig, worker_threads = 1, provides = ["ai.tinyhumans.tinysecurity.Policy"], - methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], + methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], signals = ["PolicyChanged"], requires = [], optional = ["ai.tinyhumans.tinysecurity.KeyringConsent2", "ai.tinyhumans.tinysecurity.SecretCipherConsumer2", "ai.tinyhumans.tinysecurity.SecretBackend2", "ai.tinyhumans.tinysecurity.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], diff --git a/crates/tinysecurity-module/src/adapter_crypto_tests.rs b/crates/tinysecurity-module/src/adapter_crypto_tests.rs index dc359587..45e74fd8 100644 --- a/crates/tinysecurity-module/src/adapter_crypto_tests.rs +++ b/crates/tinysecurity-module/src/adapter_crypto_tests.rs @@ -473,6 +473,7 @@ async fn members_require_confidential_transport_and_the_authenticated_peer() names::methods::PASSWORD_CRYPT2, names::methods::KEYRING_ACCESS2, names::methods::PAIRING_CONTROL2, + names::methods::DEVICE_FRAME2, ] { let member: tinybus::MemberName = name.parse()?; assert!(fixture.service.requires_confidential(&member)); diff --git a/crates/tinysecurity-module/src/adapter_device.rs b/crates/tinysecurity-module/src/adapter_device.rs index 054683d2..062f0bf9 100644 --- a/crates/tinysecurity-module/src/adapter_device.rs +++ b/crates/tinysecurity-module/src/adapter_device.rs @@ -5,8 +5,8 @@ use tinysecurity::crypto::device_handles::{ ConfidentialConfig, ConfidentialRequest, Registry, Time, }; use tinysecurity_bus::{ - ApprovalChoice2, ApprovalLifecycle2, DeviceConfig2, DeviceResponse2, EvaluateDeviceRequest2, - ExecuteDeviceRequest2, + ApprovalChoice2, ApprovalLifecycle2, DeviceConfig2, DeviceFrameOperation2, DeviceFrameRequest2, + DeviceFrameResponse2, DeviceResponse2, EvaluateDeviceRequest2, ExecuteDeviceRequest2, }; // A weak reinit lookup does not extend the lifetime of native credentials past shutdown. static DEVICE: OnceLock>> = OnceLock::new(); @@ -146,6 +146,45 @@ impl ConfidentialResponse { self.0.take().ok_or_else(denied) } } + +struct ConfidentialFrameRequest(DeviceFrameRequest2); +impl ConfidentialFrameRequest { + fn new(request: DeviceFrameRequest2) -> Self { + Self(request) + } +} +impl Drop for ConfidentialFrameRequest { + fn drop(&mut self) { + match &mut self.0.operation { + DeviceFrameOperation2::Seal { plaintext } => { + use zeroize::Zeroize; + plaintext.zeroize(); + } + DeviceFrameOperation2::Open { frame } => { + use zeroize::Zeroize; + frame.zeroize(); + } + } + } +} + +struct ConfidentialFrameResponse(Option); +impl ConfidentialFrameResponse { + fn new(response: DeviceFrameResponse2) -> Self { + Self(Some(response)) + } + fn publish(mut self) -> BusResult { + self.0.take().ok_or_else(denied) + } +} +impl Drop for ConfidentialFrameResponse { + fn drop(&mut self) { + if let Some(response) = &mut self.0 { + use zeroize::Zeroize; + response.bytes.zeroize(); + } + } +} impl Drop for ConfidentialResponse { fn drop(&mut self) { if let Some(response) = &mut self.0 { @@ -268,6 +307,21 @@ impl PolicyService { } response.publish() } + + /// Reuse only an opaque tunnel capability that the approved device path created. + /// Frame calls are synchronous and do not park for another human decision. + pub(super) fn device_frame( + &self, + request: DeviceFrameRequest2, + ) -> BusResult { + let request = ConfidentialFrameRequest::new(request); + let registry = Arc::clone(self.device.as_ref().ok_or_else(denied)?); + let generation = self.read()?.info().generation; + let response = registry + .execute_frame(&request.0, generation, self.device_time()?) + .map_err(|_| denied())?; + ConfidentialFrameResponse::new(response).publish() + } } #[cfg(test)] #[path = "adapter_device_tests.rs"] diff --git a/crates/tinysecurity-module/src/adapter_device_tests.rs b/crates/tinysecurity-module/src/adapter_device_tests.rs index aada1387..5a0bca00 100644 --- a/crates/tinysecurity-module/src/adapter_device_tests.rs +++ b/crates/tinysecurity-module/src/adapter_device_tests.rs @@ -41,9 +41,122 @@ async fn unconfigured_device_service_denies_before_creating_native_authority() .await .is_err() ); + assert!( + service + .device_frame(tinysecurity_bus::DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: "missing-tunnel".into(), + call_id: "frame-call".into(), + operation: tinysecurity_bus::DeviceFrameOperation2::Seal { + plaintext: b"private frame".to_vec(), + }, + }) + .is_err() + ); assert!(time(&FailedClock).is_err()); Ok(()) } + +#[test] +fn module_frame_member_seals_only_through_a_live_tunnel_handle() +-> std::result::Result<(), Box> { + use tinysecurity_bus::*; + let scope_id = "c".repeat(32); + let registered = ApprovalScope2 { + scope_id: scope_id.clone(), + subject: SubjectScope { + user: "user".into(), + workspace: "workspace".into(), + agent: "agent".into(), + }, + policy_ref: "policy".into(), + generation: 1, + ceiling: AccessTier::Full, + require_approval: true, + auto_approve_all: false, + }; + let registry = Arc::new(Registry::new( + DeviceConfig2 { + scopes: vec![DeviceScope2 { + scope_id: scope_id.clone(), + origin: Origin::Operator, + static_secret: Some([11; 32]), + }], + max_handles: 8, + max_in_flight: 2, + operations_per_minute: 100, + frames_per_minute: 12_000, + max_ttl_ms: 100, + max_message_bytes: 1024, + max_bootstrap_nonces: 8, + }, + &[registered], + ":1.1", + 1, + Time { + wall: 1000, + monotonic: 0, + }, + )?); + let execute = |operation| -> std::result::Result { + let request = DeviceRequest2 { + scope_id: scope_id.clone(), + call_id: "approved-tunnel-setup".into(), + ttl_ms: 100, + operation, + }; + let now = Time { + wall: 1000, + monotonic: 0, + }; + let work = registry.reserve(&request, 1, now)?; + registry.execute(&work, &request.operation, 1, now) + }; + let static_handle = execute(DeviceOperation2::Key { + purpose: DeviceKeyPurpose2::Static, + })? + .handle + .ok_or("missing static handle")?; + let ephemeral = execute(DeviceOperation2::Key { + purpose: DeviceKeyPurpose2::Ephemeral, + })?; + let remote_static = + tinysecurity::crypto::device::KeyPair::from_secret(zeroize::Zeroizing::new([12; 32]))?; + let remote_ephemeral = + tinysecurity::crypto::device::KeyPair::from_secret(zeroize::Zeroizing::new([13; 32]))?; + let tunnel = execute(DeviceOperation2::Tunnel { + key: static_handle, + ephemeral: ephemeral.handle.ok_or("missing ephemeral handle")?, + peer_static: remote_static.public_key().encode(), + peer_ephemeral: remote_ephemeral.public_key().encode(), + role: DeviceRole2::Client, + })? + .handle + .ok_or("missing tunnel handle")?; + + let mut service = super::super::service_for( + Arc::new(std::sync::RwLock::new(tinysecurity::Policy::new( + ModuleConfig::default(), + )?)), + None, + ); + service.device = Some(registry); + service.sandbox_clock = Arc::new(ToggleClock(std::sync::atomic::AtomicBool::new(false))); + assert_eq!(service.read()?.info().generation, 1); + assert_eq!(time(service.sandbox_clock.as_ref())?.wall, 1000); + let response = service.device_frame(DeviceFrameRequest2 { + scope_id, + handle: tunnel, + call_id: "socket-send".into(), + operation: DeviceFrameOperation2::Seal { + plaintext: b"socket frame".to_vec(), + }, + })?; + assert_eq!(response.bytes.first(), Some(&2)); + assert_eq!(format!("{response:?}"), "DeviceFrameResponse2([REDACTED])"); + Ok(()) +} + #[derive(Debug)] struct FailedClock; impl tinysecurity::approval::Clock for FailedClock { @@ -92,6 +205,7 @@ fn clock_failure_invalidates_existing_device_authority_permanently() max_handles: 10, max_in_flight: 2, operations_per_minute: 100, + frames_per_minute: 12_000, max_ttl_ms: 100, max_message_bytes: 100, max_bootstrap_nonces: 10, @@ -191,6 +305,7 @@ fn device_configuration() -> DeviceConfig2 { max_handles: 16, max_in_flight: 4, operations_per_minute: 4096, + frames_per_minute: 12_000, max_ttl_ms: 300_000, max_message_bytes: 1024, max_bootstrap_nonces: 64, From 5bae43de450f190e6d1e20f309f499c7ef2a6438 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:27:08 +0300 Subject: [PATCH 2/8] test(module): exercise confidential device frames natively --- crates/tinysecurity-bus/src/device.rs | 5 +- .../tinysecurity-crypto/src/device_handles.rs | 5 +- .../examples/device_native_contract.rs | 225 ++++++++++++++++-- .../device_native_contract/fixture.rs | 32 ++- 4 files changed, 243 insertions(+), 24 deletions(-) diff --git a/crates/tinysecurity-bus/src/device.rs b/crates/tinysecurity-bus/src/device.rs index 53b59800..f60aeae4 100644 --- a/crates/tinysecurity-bus/src/device.rs +++ b/crates/tinysecurity-bus/src/device.rs @@ -13,8 +13,9 @@ pub struct DeviceConfig2 { pub max_in_flight: u32, /// Operations per scope per monotonic minute, in 1..=4096. pub operations_per_minute: u32, - /// Frames per live tunnel per monotonic minute; defaults to 12000 (200 frames/second). - /// This budget is independent of control-plane `operations_per_minute`. + /// Frames per live tunnel per monotonic minute, in 1..=60000; defaults to 12000 + /// (200 frames/second). This budget is independent of control-plane + /// `operations_per_minute`. #[serde(default = "default_frames_per_minute")] pub frames_per_minute: u32, /// Maximum handle and operation lifetime, in 1..=300000 milliseconds. diff --git a/crates/tinysecurity-crypto/src/device_handles.rs b/crates/tinysecurity-crypto/src/device_handles.rs index 62f1ffd9..059a6a18 100644 --- a/crates/tinysecurity-crypto/src/device_handles.rs +++ b/crates/tinysecurity-crypto/src/device_handles.rs @@ -299,8 +299,9 @@ impl Registry { /// /// The opaque tunnel handle is the capability. This fast path accepts no key material and /// cannot create or extend a session; it checks the registered scope, live generation, - /// expiry, revocation epoch, message bound and the same per-scope operation budget as the - /// approval path. A revoked, expired or reconfigured handle is unusable immediately. + /// expiry, revocation epoch and message bound. Each tunnel has its own rolling frame + /// budget; it is independent of the per-scope control-operation budget. A revoked, + /// expired or reconfigured handle is unusable immediately. /// /// # Errors /// Rejects an unknown or stale tunnel, malformed frame, replay, oversized payload or limit. diff --git a/crates/tinysecurity-module/examples/device_native_contract.rs b/crates/tinysecurity-module/examples/device_native_contract.rs index f537f4d1..cb1c4547 100644 --- a/crates/tinysecurity-module/examples/device_native_contract.rs +++ b/crates/tinysecurity-module/examples/device_native_contract.rs @@ -9,8 +9,9 @@ use std::{ }; use tinybus::{Connection, broker::Broker, module::ModuleHost, transport::memory::MemoryBus}; use tinysecurity_bus::{ - ApprovalConfig2, ApprovalRow2, DeviceKeyPurpose2, DeviceOperation2, DeviceResponse2, - DeviceRole2, EvaluateDeviceRequest2, ExecuteDeviceRequest2, ModuleConfig, PolicyInfo, names, + ApprovalConfig2, ApprovalRow2, DeviceFrameOperation2, DeviceFrameRequest2, + DeviceFrameResponse2, DeviceKeyPurpose2, DeviceOperation2, DeviceResponse2, DeviceRole2, + EvaluateDeviceRequest2, ExecuteDeviceRequest2, ModuleConfig, PolicyInfo, names, }; #[tokio::main] async fn main() -> std::result::Result<(), Box> { @@ -43,7 +44,7 @@ async fn main() -> std::result::Result<(), Box> { trusted_host: host.unique_name().map(|n| n.to_string()), approvals: Some(ApprovalConfig2 { timeout_ms: 30_000, - scopes: vec![fixture::registration()], + scopes: vec![fixture::registration(), fixture::other_registration()], }), audit: Some(fixture::audit_config(Some( root.join("audit").to_str().ok_or("fixture path")?.into(), @@ -62,7 +63,7 @@ async fn main() -> std::result::Result<(), Box> { .with_timeout(Duration::from_secs(10)); routing_checks(&proxy, &bus, &module).await?; let mut sequence = 1; - let key = native_protocols(&proxy, &store, &mut sequence).await?; + let (key, stale_tunnel) = native_protocols(&proxy, &store, &mut sequence).await?; let observer = generation_boundary( &lifecycle, &loaded.name, @@ -70,7 +71,7 @@ async fn main() -> std::result::Result<(), Box> { &proxy, &store, &mut sequence, - key, + (key, stale_tunnel), ) .await?; let after: PolicyInfo = proxy.call(names::methods::POLICY_INFO, ()).await?; @@ -104,7 +105,7 @@ async fn main() -> std::result::Result<(), Box> { task.abort(); assert_eq!(hash, tinybus::module::sha256_file(&library)?); println!( - "native device: SHA256={hash}; verified peer + confidential RPCs + exact human/audit receipts + v2/v3/v4 + replay/revoke/reinit/shutdown passed" + "native device: SHA256={hash}; verified peer + confidential DeviceFrame2 Seal/Open + no frame approval rows + cross-scope/replay/revoke/stale-generation denial + existing approved v2/v3/v4 flows + shutdown passed" ); Ok(()) } @@ -114,7 +115,7 @@ async fn native_tunnel( sequence: &mut u64, own: &str, public: &str, -) -> std::result::Result<(), Box> { +) -> std::result::Result> { use tinysecurity::crypto::device::{KeyPair, PublicKey, Role, Tunnel}; let ephemeral = fixture::run( proxy, @@ -184,13 +185,159 @@ async fn native_tunnel( store, sequence, DeviceOperation2::Open { - handle: tunnel, + handle: tunnel.clone(), frame: incoming } ) .await .is_err() ); + verify_frame_dispatch(proxy, store, &tunnel, &mut peer_tunnel).await?; + verify_revoked_tunnel(proxy, store, sequence, own).await?; + Ok(tunnel) +} + +async fn verify_frame_dispatch( + proxy: &tinybus::Proxy, + store: &Arc>, + tunnel: &str, + peer_tunnel: &mut tinysecurity::crypto::device::Tunnel, +) -> std::result::Result<(), Box> { + let before = { + let store = store + .lock() + .map_err(|_| tinybus::Error::failed("fixture store"))?; + (store.mutations.len(), store.tickets.len()) + }; + let sealed: DeviceFrameResponse2 = proxy + .call_confidential( + names::methods::DEVICE_FRAME2, + (DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: tunnel.into(), + call_id: "module-frame-seal".into(), + operation: DeviceFrameOperation2::Seal { + plaintext: b"private-secret".to_vec(), + }, + },), + ) + .await?; + assert_eq!(sealed.bytes.first(), Some(&2)); + assert_eq!(&*peer_tunnel.open(&sealed.bytes)?, b"private-secret"); + let incoming = peer_tunnel.seal(b"private-secret")?; + let opened: DeviceFrameResponse2 = proxy + .call_confidential( + names::methods::DEVICE_FRAME2, + (DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: tunnel.into(), + call_id: "module-frame-open".into(), + operation: DeviceFrameOperation2::Open { + frame: incoming.clone(), + }, + },), + ) + .await?; + assert_eq!(opened.bytes, b"private-secret"); + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_FRAME2, + (DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: tunnel.into(), + call_id: "module-frame-replay".into(), + operation: DeviceFrameOperation2::Open { frame: incoming }, + },), + ) + .await + .is_err() + ); + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_FRAME2, + (DeviceFrameRequest2 { + scope_id: "b".repeat(32), + handle: tunnel.into(), + call_id: "module-frame-cross-scope".into(), + operation: DeviceFrameOperation2::Seal { + plaintext: b"private-secret".to_vec() + }, + },), + ) + .await + .is_err() + ); + let after = { + let store = store + .lock() + .map_err(|_| tinybus::Error::failed("fixture store"))?; + (store.mutations.len(), store.tickets.len()) + }; + assert_eq!( + after, before, + "frame calls must not create approval rows or tickets" + ); + Ok(()) +} + +async fn verify_revoked_tunnel( + proxy: &tinybus::Proxy, + store: &Arc>, + sequence: &mut u64, + own: &str, +) -> std::result::Result<(), Box> { + use tinysecurity::crypto::device::KeyPair; + let revoked_ephemeral = fixture::run( + proxy, + store, + sequence, + DeviceOperation2::Key { + purpose: DeviceKeyPurpose2::Ephemeral, + }, + ) + .await?; + let revoked_peer = KeyPair::generate()?; + let revoked_peer_ephemeral = KeyPair::generate()?; + let revoked = fixture::run( + proxy, + store, + sequence, + DeviceOperation2::Tunnel { + key: own.into(), + ephemeral: revoked_ephemeral.handle.ok_or("fixture ephemeral")?, + peer_static: revoked_peer.public_key().encode(), + peer_ephemeral: revoked_peer_ephemeral.public_key().encode(), + role: DeviceRole2::Client, + }, + ) + .await? + .handle + .ok_or("fixture revoked tunnel")?; + fixture::run( + proxy, + store, + sequence, + DeviceOperation2::Revoke { + handle: revoked.clone(), + }, + ) + .await?; + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_FRAME2, + (DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: revoked, + call_id: "module-frame-after-revoke".into(), + operation: DeviceFrameOperation2::Seal { plaintext: vec![] }, + },), + ) + .await + .is_err() + ); Ok(()) } async fn routing_checks( @@ -204,6 +351,10 @@ async fn routing_checks( info.capabilities .contains(&names::methods::EXECUTE_DEVICE2.to_owned()) ); + assert!( + info.capabilities + .contains(&names::methods::DEVICE_FRAME2.to_owned()) + ); assert!(!serde_json::to_string(&info)?.contains("static_secret")); let rejected = EvaluateDeviceRequest2 { request: fixture::request(1, DeviceOperation2::Cancel), @@ -223,6 +374,33 @@ async fn routing_checks( .await .is_err() ); + let unconfigured_frame = DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: "missing-tunnel".into(), + call_id: "untrusted-frame".into(), + operation: DeviceFrameOperation2::Seal { plaintext: vec![] }, + }; + assert!( + proxy + .call::( + names::methods::DEVICE_FRAME2, + (unconfigured_frame.clone(),), + ) + .await + .is_err(), + "DeviceFrame2 requires confidential transport" + ); + assert!( + attacker + .proxy(module.as_str(), names::OBJECT_PATH, names::INTERFACE)? + .call_confidential::( + names::methods::DEVICE_FRAME2, + (unconfigured_frame,), + ) + .await + .is_err(), + "untrusted peers cannot use DeviceFrame2" + ); let malformed = serde_json::json!([{"request":{"scope_id":"a","call_id":"private-secret","ttl_ms":1,"operation":{"kind":"seal","handle":"private-secret","plaintext":"private-secret"}},"audit":fixture::audit(1)}]); let error = proxy .call_confidential::(names::methods::EVALUATE_DEVICE2, malformed) @@ -284,7 +462,7 @@ async fn native_protocols( proxy: &tinybus::Proxy, store: &Arc>, sequence: &mut u64, -) -> std::result::Result> { +) -> std::result::Result<(String, String), Box> { let server = fixture::run( proxy, store, @@ -367,7 +545,7 @@ async fn native_protocols( .await .is_err() ); - let key = fixture::run( + let observer = fixture::run( proxy, store, sequence, @@ -375,10 +553,11 @@ async fn native_protocols( purpose: DeviceKeyPurpose2::Static, }, ) - .await? - .handle - .ok_or("fixture handle")?; - Ok(key) + .await?; + let observer_public = observer.public_key.ok_or("fixture public")?; + let observer = observer.handle.ok_or("fixture handle")?; + let stale_tunnel = native_tunnel(proxy, store, sequence, &observer, &observer_public).await?; + Ok((observer, stale_tunnel)) } async fn generation_boundary( lifecycle: &ModuleHost, @@ -387,8 +566,9 @@ async fn generation_boundary( proxy: &tinybus::Proxy, store: &Arc>, sequence: &mut u64, - key: String, + handles: (String, String), ) -> std::result::Result> { + let (key, stale_tunnel) = handles; // Actual SDK reinit retains the callback peer/registrations while advancing generation. for scope in &mut configuration .device @@ -418,6 +598,21 @@ async fn generation_boundary( .await .is_err() ); + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_FRAME2, + (DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: stale_tunnel, + call_id: "module-frame-after-reinit".into(), + operation: DeviceFrameOperation2::Seal { plaintext: vec![] }, + },), + ) + .await + .is_err(), + "reinitialization invalidates old tunnel capabilities" + ); let current = fixture::run_at( proxy, store, diff --git a/crates/tinysecurity-module/examples/device_native_contract/fixture.rs b/crates/tinysecurity-module/examples/device_native_contract/fixture.rs index 923268be..b4a0ea89 100644 --- a/crates/tinysecurity-module/examples/device_native_contract/fixture.rs +++ b/crates/tinysecurity-module/examples/device_native_contract/fixture.rs @@ -32,13 +32,35 @@ pub(crate) fn registration() -> ApprovalScope2 { auto_approve_all: false, } } +pub(crate) fn other_registration() -> ApprovalScope2 { + ApprovalScope2 { + scope_id: "b".repeat(32), + subject: SubjectScope { + user: "fixture-other-user".into(), + workspace: "fixture-other-workspace".into(), + agent: "fixture-other-agent".into(), + }, + policy_ref: "immutable-other-device-fixture".into(), + generation: 1, + ceiling: AccessTier::Full, + require_approval: true, + auto_approve_all: false, + } +} pub(crate) fn device_config() -> DeviceConfig2 { DeviceConfig2 { - scopes: vec![DeviceScope2 { - scope_id: "a".repeat(32), - origin: Origin::Operator, - static_secret: Some([7; 32]), - }], + scopes: vec![ + DeviceScope2 { + scope_id: "a".repeat(32), + origin: Origin::Operator, + static_secret: Some([7; 32]), + }, + DeviceScope2 { + scope_id: "b".repeat(32), + origin: Origin::Operator, + static_secret: Some([8; 32]), + }, + ], max_handles: 32, max_in_flight: 4, operations_per_minute: 4096, From f621ca0749720fe54594356ebf94e86092364c5d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:16:22 +0300 Subject: [PATCH 3/8] fix(module): offload device tunnel crypto --- .../tinysecurity-crypto/src/device_handles.rs | 223 +++++++++++------- .../src/device_handles_tests.rs | 44 +++- crates/tinysecurity-module/src/adapter.rs | 2 +- .../tinysecurity-module/src/adapter_device.rs | 173 +++++++++----- .../src/adapter_device_tests.rs | 72 +++++- 5 files changed, 362 insertions(+), 152 deletions(-) diff --git a/crates/tinysecurity-crypto/src/device_handles.rs b/crates/tinysecurity-crypto/src/device_handles.rs index 059a6a18..9c4ff23a 100644 --- a/crates/tinysecurity-crypto/src/device_handles.rs +++ b/crates/tinysecurity-crypto/src/device_handles.rs @@ -32,7 +32,7 @@ struct Scope { } enum Native { Key(Arc, DeviceKeyPurpose2), - Tunnel(Tunnel, String, String), + Tunnel(Arc>, String, String), } struct TunnelInput<'a> { key: &'a str, @@ -288,6 +288,51 @@ impl Registry { if !Arc::ptr_eq(&self.0, &work.registry.0) { return Err(Error::DeviceDenied); } + if let DeviceOperation2::Seal { handle, plaintext } = operation { + let tunnel = { + let mut state = self.lock()?; + state.check_time(now)?; + state.live(work, generation, now)?; + state.prune(now); + check_operation(operation, state.limits.max_message_bytes)?; + match &state.entry(handle, work)?.native { + Native::Tunnel(tunnel, _, _) => Arc::clone(tunnel), + Native::Key(..) => return Err(Error::DeviceDenied), + } + }; + let bytes = tunnel + .lock() + .map_err(|_| Error::DeviceDenied)? + .seal(plaintext)?; + return Ok(DeviceResponse2 { + handle: None, + public_key: None, + bytes, + }); + } + if let DeviceOperation2::Open { handle, frame } = operation { + let tunnel = { + let mut state = self.lock()?; + state.check_time(now)?; + state.live(work, generation, now)?; + state.prune(now); + check_operation(operation, state.limits.max_message_bytes)?; + match &state.entry(handle, work)?.native { + Native::Tunnel(tunnel, _, _) => Arc::clone(tunnel), + Native::Key(..) => return Err(Error::DeviceDenied), + } + }; + let bytes = tunnel + .lock() + .map_err(|_| Error::DeviceDenied)? + .open(frame)? + .to_vec(); + return Ok(DeviceResponse2 { + handle: None, + public_key: None, + bytes, + }); + } let mut state = self.lock()?; state.check_time(now)?; state.live(work, generation, now)?; @@ -311,79 +356,111 @@ impl Registry { generation: u64, now: Time, ) -> Result { + let tunnel = { + let mut state = self.lock()?; + state.check_time(now)?; + state.prune(now); + if generation != state.generation + || request.scope_id.is_empty() + || request.scope_id.len() > 128 + || request.handle.is_empty() + || request.handle.len() > 64 + || request.call_id.is_empty() + || request.call_id.len() > 128 + { + return Err(Error::DeviceDenied); + } + let scope = state + .scopes + .get(&request.scope_id) + .ok_or(Error::DeviceDenied)?; + let entry = state + .handles + .get(&request.handle) + .filter(|entry| entry.scope == request.scope_id && entry.epoch == scope.epoch) + .ok_or(Error::DeviceDenied)?; + let Native::Tunnel(_, _, _) = &entry.native else { + return Err(Error::DeviceDenied); + }; + match &request.operation { + DeviceFrameOperation2::Seal { plaintext } => { + if plaintext.len() > state.limits.max_message_bytes as usize { + return Err(Error::Limit); + } + } + DeviceFrameOperation2::Open { frame } => { + if frame.len() > state.limits.max_message_bytes as usize + 41 { + return Err(Error::Limit); + } + } + } + let max_in_flight = state.limits.max_in_flight; + let max_rate = state.limits.frames_per_minute; + let State { + budget, handles, .. + } = &mut *state; + let entry = handles + .get_mut(&request.handle) + .filter(|entry| entry.scope == request.scope_id) + .ok_or(Error::DeviceDenied)?; + budget + .reserve( + &mut entry.frame_rate, + now.monotonic, + max_in_flight, + max_rate, + ) + .map_err(|error| match error { + BudgetError::Capacity => Error::DeviceCapacity, + BudgetError::Rate => Error::DeviceRate, + })?; + let Native::Tunnel(tunnel, _, _) = &entry.native else { + return Err(Error::DeviceDenied); + }; + Arc::clone(tunnel) + }; + let result = tunnel + .lock() + .map_err(|_| Error::DeviceDenied) + .and_then(|mut tunnel| { + let bytes = match &request.operation { + DeviceFrameOperation2::Seal { plaintext } => tunnel.seal(plaintext)?, + DeviceFrameOperation2::Open { frame } => tunnel.open(frame)?.to_vec(), + }; + Ok(DeviceFrameResponse2 { bytes }) + }); + self.lock()?.budget.release(); + result + } + /// Revalidate a completed confidential frame before publishing its bytes. + /// + /// This checks that the capability stayed live while frame crypto ran outside the module's + /// async worker. It does not spend another frame from the per-tunnel budget. + /// # Errors + /// Rejects changed generations, revoked or expired handles, and unavailable clocks. + pub fn validate_frame( + &self, + scope_id: &str, + handle: &str, + generation: u64, + now: Time, + ) -> Result<()> { let mut state = self.lock()?; state.check_time(now)?; state.prune(now); - if generation != state.generation - || request.scope_id.is_empty() - || request.scope_id.len() > 128 - || request.handle.is_empty() - || request.handle.len() > 64 - || request.call_id.is_empty() - || request.call_id.len() > 128 - { + if generation != state.generation { return Err(Error::DeviceDenied); } - let scope = state - .scopes - .get(&request.scope_id) - .ok_or(Error::DeviceDenied)?; + let scope = state.scopes.get(scope_id).ok_or(Error::DeviceDenied)?; let entry = state .handles - .get(&request.handle) - .filter(|entry| entry.scope == request.scope_id && entry.epoch == scope.epoch) + .get(handle) + .filter(|entry| entry.scope == scope_id && entry.epoch == scope.epoch) .ok_or(Error::DeviceDenied)?; if !matches!(entry.native, Native::Tunnel(..)) { return Err(Error::DeviceDenied); } - match &request.operation { - DeviceFrameOperation2::Seal { plaintext } => { - if plaintext.len() > state.limits.max_message_bytes as usize { - return Err(Error::Limit); - } - } - DeviceFrameOperation2::Open { frame } => { - if frame.len() > state.limits.max_message_bytes as usize + 41 { - return Err(Error::Limit); - } - } - } - let max_in_flight = state.limits.max_in_flight; - let max_rate = state.limits.frames_per_minute; - let State { - budget, handles, .. - } = &mut *state; - let entry = handles - .get_mut(&request.handle) - .filter(|entry| entry.scope == request.scope_id) - .ok_or(Error::DeviceDenied)?; - budget - .reserve( - &mut entry.frame_rate, - now.monotonic, - max_in_flight, - max_rate, - ) - .map_err(|error| match error { - BudgetError::Capacity => Error::DeviceCapacity, - BudgetError::Rate => Error::DeviceRate, - })?; - let result = (|| { - let entry = handles - .get_mut(&request.handle) - .filter(|entry| entry.scope == request.scope_id) - .ok_or(Error::DeviceDenied)?; - let Native::Tunnel(tunnel, _, _) = &mut entry.native else { - return Err(Error::DeviceDenied); - }; - let bytes = match &request.operation { - DeviceFrameOperation2::Seal { plaintext } => tunnel.seal(plaintext)?, - DeviceFrameOperation2::Open { frame } => tunnel.open(frame)?.to_vec(), - }; - Ok(DeviceFrameResponse2 { bytes }) - })(); - budget.release(); - result + Ok(()) } /// Close native authority and drop all owned credentials even if observers remain. /// Cleanup recovers a poisoned lock only to destroy state; it never restores authority. @@ -656,7 +733,7 @@ impl State { self.insert( work, Native::Tunnel( - Tunnel::new(native_role, keys), + Arc::new(Mutex::new(Tunnel::new(native_role, keys))), input.key.to_owned(), input.ephemeral.to_owned(), ), @@ -693,22 +770,8 @@ impl State { }, )?); } - Op::Seal { handle, plaintext } => match &self.entry(handle, work)?.native { - Native::Tunnel(tunnel, _, _) => response.bytes = tunnel.seal(plaintext)?, - Native::Key(..) => return Err(Error::DeviceDenied), - }, - Op::Open { handle, frame } => { - self.entry(handle, work)?; - match &mut self - .handles - .get_mut(handle) - .ok_or(Error::DeviceDenied)? - .native - { - Native::Tunnel(tunnel, _, _) => response.bytes = tunnel.open(frame)?.to_vec(), - Native::Key(..) => return Err(Error::DeviceDenied), - } - } + // Tunnel frame work is dispatched by Registry::execute outside the registry lock. + Op::Seal { .. } | Op::Open { .. } => return Err(Error::DeviceDenied), Op::SealHandshake { handle, peer, diff --git a/crates/tinysecurity-crypto/src/device_handles_tests.rs b/crates/tinysecurity-crypto/src/device_handles_tests.rs index ff9ec992..09d484d2 100644 --- a/crates/tinysecurity-crypto/src/device_handles_tests.rs +++ b/crates/tinysecurity-crypto/src/device_handles_tests.rs @@ -621,7 +621,7 @@ fn confidential_frames_reuse_only_a_live_approved_tunnel_capability() -> TestRes let sealed = reg.execute_frame( &DeviceFrameRequest2 { scope_id: "a".repeat(32), - handle: client, + handle: client.clone(), call_id: "socket-send-1".into(), operation: DeviceFrameOperation2::Seal { plaintext: b"confidential socket frame".to_vec(), @@ -696,6 +696,48 @@ fn confidential_frames_reuse_only_a_live_approved_tunnel_capability() -> TestRes Ok(()) } +#[test] +fn frame_revalidation_rejects_a_tunnel_after_generation_change() -> TestResult { + let reg = registry()?; + let (static_handle, _) = key(®, 'a', DeviceKeyPurpose2::Static)?; + let (ephemeral, _) = key(®, 'a', DeviceKeyPurpose2::Ephemeral)?; + let remote = KeyPair::from_secret(Zeroizing::new([31; 32]))?; + let remote_ephemeral = KeyPair::from_secret(Zeroizing::new([32; 32]))?; + let handle = tunnel( + ®, + &static_handle, + &ephemeral, + &remote.public_key().encode(), + &remote_ephemeral.public_key().encode(), + DeviceRole2::Client, + )?; + assert!( + reg.validate_frame(&"a".repeat(32), &handle, 1, time(0)) + .is_ok() + ); + run( + ®, + 'a', + DeviceOperation2::Revoke { + handle: handle.clone(), + }, + )?; + assert!( + reg.validate_frame(&"a".repeat(32), &handle, 1, time(0)) + .is_err() + ); + let mut next = config(); + for scope in &mut next.scopes { + scope.static_secret = None; + } + reg.reconfigure(next, 2)?; + assert!( + reg.validate_frame(&"a".repeat(32), &handle, 1, time(0)) + .is_err() + ); + Ok(()) +} + #[test] fn confidential_frames_enforce_size_limits_expiry_and_replay() -> TestResult { let mut cfg = config(); diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 662e2a98..e7faef47 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -313,7 +313,7 @@ impl PolicyService { &self, request: tinysecurity_bus::DeviceFrameRequest2, ) -> BusResult { - std::future::ready(self.device_frame(request)).await + self.device_frame(request).await } async fn plan( &self, diff --git a/crates/tinysecurity-module/src/adapter_device.rs b/crates/tinysecurity-module/src/adapter_device.rs index 062f0bf9..d410f60f 100644 --- a/crates/tinysecurity-module/src/adapter_device.rs +++ b/crates/tinysecurity-module/src/adapter_device.rs @@ -19,6 +19,18 @@ fn time(clock: &dyn tinysecurity::approval::Clock) -> BusResult