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
7 changes: 7 additions & 0 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,13 @@ cannot bind the call answers an error, never an allow. Latency budgets are per c
`Verdict::Deny { reason: DenialReason::Privacy }`, `cacheable: false`, stamped with the live
policy generation. Budget: a pure in-memory decision. Refused with required audit.

## Native credential-store roots (contract 1.24)

- `CredentialRoots2` (`CredentialRootsRequest2` -> `CredentialRootsResponse2`): resolves bounded
native credential-store denial roots from the host's optional operator home; see
[credential roots](docs/module-credential-roots.md). Filesystem canonicalization runs on a
blocking worker so the module's async bus executor remains responsive.

## Approval origin classes, durable flow trust and scored prompt screening (contract 1.22)

Additive: every new member and enum value is absent from a 1.21 host's traffic, and a 1.22 host
Expand Down
70 changes: 70 additions & 0 deletions crates/tinysecurity-bus/src/credential_roots.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! Native credential-store roots for hosts that must hand path denials to a jail.
use serde::{Deserialize, Serialize};
use std::{fmt, path::PathBuf};

/// Maximum concrete credential roots returned for one home directory.
pub const MAX_CREDENTIAL_ROOTS: usize = 64;
/// Maximum encoded path size accepted by the native credential-root member.
pub const MAX_CREDENTIAL_ROOT_BYTES: usize = 4096;

/// Request concrete native credential-store roots for an optional operator home.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CredentialRootsRequest2 {
/// Trusted operator home supplied by the host; absent homes yield no roots.
pub home: Option<PathBuf>,
}
impl fmt::Debug for CredentialRootsRequest2 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CredentialRootsRequest2")
.field("home", &self.home.as_ref().map(|_| "<redacted>"))
.finish()
}
}

/// Native lexical and canonical aliases that a jail must deny.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CredentialRootsResponse2 {
/// Bounded absolute paths, including lexical aliases when canonicalization differs.
pub roots: Vec<PathBuf>,
}
impl fmt::Debug for CredentialRootsResponse2 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CredentialRootsResponse2")
.field(
"roots",
&format_args!("<redacted: {} paths>", self.roots.len()),
)
.finish()
}
}

impl CredentialRootsResponse2 {
/// Validate output bounds and require every root to be absolute and traversal-free.
///
/// # Errors
/// Returns `InvalidArguments` for an oversized, relative, NUL-containing, or
/// traversal-bearing result.
pub fn validate(&self) -> crate::Result<()> {
if self.roots.len() > MAX_CREDENTIAL_ROOTS
|| self.roots.iter().any(|root| {
!root.is_absolute()
|| root.as_os_str().len() > MAX_CREDENTIAL_ROOT_BYTES
|| root.to_string_lossy().contains('\0')
|| root
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
})
{
return Err(crate::Error::InvalidArguments);
}
Ok(())
}
}

#[cfg(test)]
#[path = "credential_roots_tests.rs"]
mod tests;
43 changes: 43 additions & 0 deletions crates/tinysecurity-bus/src/credential_roots_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
use super::*;

#[test]
fn absent_home_serializes_as_an_empty_root_request() -> Result<(), Box<dyn std::error::Error>> {
let request = CredentialRootsRequest2 { home: None };
assert_eq!(
serde_json::to_value(request)?,
serde_json::json!({ "home": null })
);
let response = CredentialRootsResponse2 { roots: Vec::new() };
response.validate()?;
Ok(())
}

#[test]
fn response_rejects_relative_and_unbounded_roots() {
assert!(
CredentialRootsResponse2 {
roots: vec![PathBuf::from(".ssh")],
}
.validate()
.is_err()
);
assert!(
CredentialRootsResponse2 {
roots: vec![PathBuf::from("/").join("x".repeat(MAX_CREDENTIAL_ROOT_BYTES))],
}
.validate()
.is_err()
);
}

#[test]
fn debug_redacts_home_and_resolved_paths() {
let request = CredentialRootsRequest2 {
home: Some(PathBuf::from("/secret/operator")),
};
let response = CredentialRootsResponse2 {
roots: vec![PathBuf::from("/secret/operator/.ssh")],
};
assert!(!format!("{request:?}").contains("/secret/operator"));
assert!(!format!("{response:?}").contains("/secret/operator"));
}
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/src/device_frame_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,5 @@ fn frame_operations_redact_both_payloads_without_changing_open_wire_shape()
#[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));
assert_eq!(crate::CONTRACT_VERSION, (1, 24));
}
4 changes: 3 additions & 1 deletion crates/tinysecurity-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ mod audited;
mod budget;
mod callbacks;
mod command_policy;
mod credential_roots;
mod egress;
mod error;
mod flow_trust;
Expand All @@ -44,6 +45,7 @@ pub use audited::*;
pub use budget::*;
pub use callbacks::*;
pub use command_policy::*;
pub use credential_roots::*;
pub use egress::*;
pub use error::*;
pub use flow_trust::*;
Expand All @@ -59,7 +61,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, 23);
pub const CONTRACT_VERSION: (u32, u32) = (1, 24);
/// 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
Expand Down
3 changes: 3 additions & 0 deletions crates/tinysecurity-bus/src/names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ pub mod methods {
pub const PROTECTED_PATH2: &str = "ProtectedPath2";
/// The floor's component, file, pair and prefix vocabulary (contract 1.22).
pub const PROTECTED_FLOOR2: &str = "ProtectedFloor2";
/// Resolves concrete native credential-store roots for a host-provided home.
pub const CREDENTIAL_ROOTS2: &str = "CredentialRoots2";
/// Reserve one action from a registered command scope's hourly ledger (contract 1.22).
pub const SPEND_ACTION2: &str = "SpendAction2";
/// Read a registered command scope's hourly ledger without spending (contract 1.22).
Expand Down Expand Up @@ -187,6 +189,7 @@ pub const METHODS: &[&str] = &[
methods::CHECK_PATHS2,
methods::PROTECTED_PATH2,
methods::PROTECTED_FLOOR2,
methods::CREDENTIAL_ROOTS2,
methods::SPEND_ACTION2,
methods::PEEK_BUDGET2,
methods::CLASSIFY_HOST_ACTION2,
Expand Down
1 change: 1 addition & 0 deletions crates/tinysecurity-bus/src/path_batch_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ fn member_names_are_stable_and_advertised() {
(crate::names::methods::CHECK_PATHS2, "CheckPaths2"),
(crate::names::methods::PROTECTED_PATH2, "ProtectedPath2"),
(crate::names::methods::PROTECTED_FLOOR2, "ProtectedFloor2"),
(crate::names::methods::CREDENTIAL_ROOTS2, "CredentialRoots2"),
] {
assert_eq!(name, wire);
assert!(crate::names::METHODS.contains(&name));
Expand Down
7 changes: 4 additions & 3 deletions crates/tinysecurity-bus/tests/scoped_wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +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, 23));
// A host built against 1.23 accepts this module and newer minors, not a 1.22 module.
assert!(is_compatible((1, 23)));
assert_eq!(CONTRACT_VERSION, (1, 24));
// A host built against 1.24 accepts this module and newer minors, not a 1.23 module.
assert!(is_compatible((1, 24)));
assert!(!is_compatible((1, 23)));
assert!(!is_compatible((1, 22)));
assert!(!is_compatible((1, 21)));
assert!(!is_compatible((1, 20)));
Expand Down
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/tests/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, 23));
assert_eq!(CONTRACT_VERSION, (1, 24));
for (mode, wire) in [
(RedactMode::Text, "text"),
(RedactMode::StructuredArguments, "structured_arguments"),
Expand Down
40 changes: 39 additions & 1 deletion crates/tinysecurity-module/examples/device_native_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
let proxy = host
.proxy(module.as_str(), names::OBJECT_PATH, names::INTERFACE)?
.with_timeout(Duration::from_secs(10));
credential_root_checks(&proxy).await?;
routing_checks(&proxy, &bus, &module).await?;
let mut sequence = 1;
let (key, stale_tunnel) = native_protocols(&proxy, &store, &mut sequence).await?;
Expand Down Expand Up @@ -105,10 +106,47 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
task.abort();
assert_eq!(hash, tinybus::module::sha256_file(&library)?);
println!(
"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"
"native device: SHA256={hash}; verified CredentialRoots2 native resolution + home-none isolation + 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(())
}

async fn credential_root_checks(
proxy: &tinybus::Proxy,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
use tinysecurity_bus::{CredentialRootsRequest2, CredentialRootsResponse2};

let home_variable = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
let home = std::env::var_os(home_variable)
.map(std::path::PathBuf::from)
.ok_or("native fixture home is unavailable")?;
let response: CredentialRootsResponse2 = proxy
.call(
names::methods::CREDENTIAL_ROOTS2,
CredentialRootsRequest2 {
home: Some(home.clone()),
},
)
.await?;
response.validate()?;
assert!(response.roots.contains(&home.join(".ssh")));
#[cfg(target_os = "macos")]
assert!(response.roots.contains(&home.join("Library/Keychains")));
#[cfg(windows)]
assert!(
response
.roots
.contains(&home.join("AppData/Roaming/Microsoft/Protect"))
);
let absent: CredentialRootsResponse2 = proxy
.call(
names::methods::CREDENTIAL_ROOTS2,
CredentialRootsRequest2 { home: None },
)
.await?;
assert_eq!(absent.roots, Vec::<std::path::PathBuf>::new());
Ok(())
}
async fn native_tunnel(
proxy: &tinybus::Proxy,
store: &Arc<Mutex<fixture::store::DiskStore>>,
Expand Down
16 changes: 15 additions & 1 deletion crates/tinysecurity-module/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,20 @@ impl PolicyService {
async fn protected_floor2(&self) -> BusResult<tinysecurity_bus::ProtectedFloor2> {
std::future::ready(Ok(tinysecurity::floor_vocabulary())).await
}
async fn credential_roots2(
&self,
request: tinysecurity_bus::CredentialRootsRequest2,
) -> BusResult<tinysecurity_bus::CredentialRootsResponse2> {
let roots = tokio::task::spawn_blocking(move || {
tinysecurity::credential_roots(request.home.as_deref())
})
.await
.map_err(|_| tinybus::Error::failed("credential root resolution unavailable"))?
.map_err(|error| failure(&error))?;
let response = tinysecurity_bus::CredentialRootsResponse2 { roots };
response.validate().map_err(|error| failure(&error))?;
std::future::ready(Ok(response)).await
}
async fn spend_action2(
&self,
request: tinysecurity_bus::BudgetRequest2,
Expand Down Expand Up @@ -1952,7 +1966,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", "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"],
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", "CredentialRoots2", "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"],
Expand Down
Loading
Loading