diff --git a/README.md b/README.md index 490afc03..e3585034 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..64e0c618 100644 --- a/crates/tinysecurity-bus/src/device.rs +++ b/crates/tinysecurity-bus/src/device.rs @@ -13,6 +13,11 @@ 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, 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. pub max_ttl_ms: u32, /// Plaintext bound, in 1..=1048576 bytes. @@ -20,6 +25,10 @@ pub struct DeviceConfig2 { /// Authenticated bootstrap nonces retained across the registry, in 1..=4096. pub max_bootstrap_nonces: u32, } +/// Default independent tunnel-frame budget: 12,000 frames per monotonic minute. +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..9857f3ce --- /dev/null +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -0,0 +1,74 @@ +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_operations_redact_both_payloads_without_changing_open_wire_shape() +-> std::result::Result<(), Box> { + let seal = DeviceFrameOperation2::Seal { + plaintext: b"private seal payload".to_vec(), + }; + let open = DeviceFrameOperation2::Open { + frame: b"private open frame".to_vec(), + }; + + for operation in [&seal, &open] { + let debug = format!("{operation:?}"); + assert_eq!(debug, "DeviceFrameOperation2([REDACTED])"); + assert!(!debug.contains("private")); + } + + let wire = serde_json::to_value(&open)?; + assert_eq!( + wire, + serde_json::json!({ + "kind": "open", + "frame": b"private open frame" + }) + ); + assert_eq!(serde_json::from_value::(wire)?, open); + 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..a0209728 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. @@ -31,7 +32,7 @@ struct Scope { } enum Native { Key(Arc, DeviceKeyPurpose2), - Tunnel(Tunnel, String, String), + Tunnel(Arc>, String, String), } struct TunnelInput<'a> { key: &'a str, @@ -45,6 +46,7 @@ struct Entry { epoch: u64, expires_wall: u64, expires_monotonic: u64, + frame_rate: RateCounter, native: Native, } struct State { @@ -82,9 +84,18 @@ impl std::fmt::Debug for Reservation { } impl Drop for Reservation { fn drop(&mut self) { - if let Ok(mut state) = self.registry.0.lock() { - state.budget.release(); - } + release_budget(&self.registry); + } +} +struct InFlightGuard(Registry); +impl Drop for InFlightGuard { + fn drop(&mut self) { + release_budget(&self.0); + } +} +fn release_budget(registry: &Registry) { + if let Ok(mut state) = registry.0.lock() { + state.budget.release(); } } impl Registry { @@ -158,6 +169,17 @@ impl Registry { fn lock(&self) -> Result> { self.0.lock().map_err(|_| Error::DeviceDenied) } + fn with_clock_sample(state: &mut State, sample: F) -> Result