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
39 changes: 38 additions & 1 deletion crates/tinysecurity-bus/src/crypto_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ pub enum KeyringBackend2 {
},
}

/// Init-only keyring settings; consent is mandatory for every operation.
/// Init-only keyring settings; consent is mandatory for every operation
/// unless a read grant covers a repeated read.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyringConfig2 {
Expand All @@ -37,6 +38,27 @@ pub struct KeyringConfig2 {
pub backend: KeyringBackend2,
/// Bounded `KeyringConsent2` callback wait, in 1..=300000 milliseconds.
pub consent_timeout_ms: u32,
/// Opt-in read grants; absent keeps one consent per operation and adds
/// nothing to the consent wire.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_grant: Option<KeyringReadGrant2>,
}

/// Longest read grant the module accepts, and the scope a read consent offers.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyringReadGrant2 {
/// Grant ceiling in 1..=86400000 milliseconds (24 hours).
pub max_ttl_ms: u32,
}

/// Read grant a host returns with an `Allowed` read consent.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyringGrant2 {
/// Requested lifetime; the module clamps it to the offered ceiling, and 0
/// grants nothing.
pub ttl_ms: u32,
}

/// One registered pairing account; tokens are legacy configuration bearer values.
Expand Down Expand Up @@ -158,6 +180,13 @@ pub enum KeyringOperation2 {
/// Secret name.
name: String,
},
/// End read grants without consent or backend access: those for one
/// entry, or every grant when `name` is absent. Needs `read_grant`.
RevokeGrants {
/// Secret name, or absent for every entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
}

/// Keyring request with verified caller facts for the consent prompt.
Expand Down Expand Up @@ -208,6 +237,10 @@ pub struct KeyringConsentRequest2 {
pub name: String,
/// Bounded wait.
pub timeout_ms: u32,
/// Read scope the host may grant; present only on a read when the host
/// configured `read_grant`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offer: Option<KeyringReadGrant2>,
}

/// Outcome from the host's keyring consent decision.
Expand All @@ -230,6 +263,10 @@ pub enum KeyringConsentOutcome2 {
pub struct KeyringConsentResponse2 {
/// Explicit outcome for exactly this request.
pub outcome: KeyringConsentOutcome2,
/// Read grant; honoured only with `Allowed` on a request that carried an
/// offer, and ignored otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grant: Option<KeyringGrant2>,
}

/// Pairing operation on a registered account.
Expand Down
51 changes: 51 additions & 0 deletions crates/tinysecurity-bus/src/crypto_ops_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ fn config_round_trips_and_omits_absent_sections() -> Result<(), Box<dyn std::err
directory: "/tmp/x".into(),
},
consent_timeout_ms: 1000,
read_grant: None,
}),
pairing: None,
};
Expand Down Expand Up @@ -102,3 +103,53 @@ fn pairing_bearer_never_appears_in_debug_output() {
};
assert!(!format!("{request:?}").contains("bearer-secret"));
}

#[test]
fn read_grant_fields_stay_off_the_wire_unless_offered() -> Result<(), Box<dyn std::error::Error>> {
let request = KeyringConsentRequest2 {
caller: CallerContext::default(),
purpose: "read a token".into(),
access: KeyringAccess2::Read,
name: "user:token".into(),
timeout_ms: 1000,
offer: None,
};
assert!(serde_json::to_value(&request)?.get("offer").is_none());
let offered = KeyringConsentRequest2 {
offer: Some(KeyringReadGrant2 { max_ttl_ms: 60_000 }),
..request
};
assert_eq!(
serde_json::to_value(&offered)?["offer"],
json!({"max_ttl_ms":60000})
);
let plain: KeyringConsentResponse2 = serde_json::from_value(json!({"outcome":"allowed"}))?;
assert_eq!(plain.grant, None);
assert!(serde_json::to_value(&plain)?.get("grant").is_none());
let granted: KeyringConsentResponse2 =
serde_json::from_value(json!({"outcome":"allowed","grant":{"ttl_ms":5000}}))?;
assert_eq!(granted.grant, Some(KeyringGrant2 { ttl_ms: 5000 }));
assert_eq!(
serde_json::to_value(KeyringOperation2::RevokeGrants { name: None })?,
json!({"kind":"revoke_grants"})
);
assert_eq!(
serde_json::to_value(KeyringOperation2::RevokeGrants {
name: Some("user:token".into())
})?,
json!({"kind":"revoke_grants","name":"user:token"})
);
let config: KeyringConfig2 = serde_json::from_value(json!({
"service":"openhuman",
"backend":{"kind":"os"},
"consent_timeout_ms":1000,
"read_grant":{"max_ttl_ms":86_400_000}
}))?;
assert_eq!(
config.read_grant,
Some(KeyringReadGrant2 {
max_ttl_ms: 86_400_000
})
);
Ok(())
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ impl Consenter {
} else {
KeyringConsentOutcome2::ConsentDenied
},
grant: None,
}))
.await
}
Expand Down Expand Up @@ -120,6 +121,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
directory: store.path().canonicalize()?.to_string_lossy().into_owned(),
},
consent_timeout_ms: 5000,
read_grant: None,
}),
pairing: None,
}),
Expand Down
126 changes: 116 additions & 10 deletions crates/tinysecurity-module/src/adapter_crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
//! Authority comes from SDK initialization and the authenticated trusted-host
//! peer. Secrets travel only over confidential transport, are wiped after use
//! and never enter `PolicyInfo`, logs, errors or audit rows. Keyring access
//! requires a fresh human consent callback for every operation.
//! requires a fresh human consent callback for every operation, except a read
//! covered by a host-granted, module-enforced read grant.
use super::{Arc, BusResult, PolicyService};
use std::sync::{Mutex, OnceLock, Weak};
use std::time::Duration;
Expand All @@ -19,10 +20,11 @@ use tinysecurity::crypto::{
secretstore::{SecretStore, Zeroizing},
};
use tinysecurity_bus::{
CryptoConfig2, KeyringAccess2, KeyringBackend2, KeyringConsentOutcome2, KeyringConsentRequest2,
KeyringConsentResponse2, KeyringOperation2, KeyringRequest2, KeyringResponse2,
MAX_CRYPTO_PAYLOAD_BYTES, PairingOperation2, PairingRequest2, PairingResponse2,
PasswordOperation2, PasswordPayload2, PasswordRequest2, PasswordResponse2, callback_names2,
AccessTier, CallerContext, CryptoConfig2, KeyringAccess2, KeyringBackend2,
KeyringConsentOutcome2, KeyringConsentRequest2, KeyringConsentResponse2, KeyringOperation2,
KeyringReadGrant2, KeyringRequest2, KeyringResponse2, MAX_CRYPTO_PAYLOAD_BYTES, Origin,
PairingOperation2, PairingRequest2, PairingResponse2, PasswordOperation2, PasswordPayload2,
PasswordRequest2, PasswordResponse2, callback_names2,
};
use zeroize::Zeroize;

Expand Down Expand Up @@ -65,6 +67,61 @@ struct Consent {
struct KeyringState {
store: Arc<dyn SecretStore>,
consent: Consent,
/// Host opt-in; absent, every operation asks.
read_grant: Option<KeyringReadGrant2>,
/// Live read grants, oldest first; memory only, so a restart or reinit
/// (which builds a new state) ends them all.
grants: Mutex<Vec<Grant>>,
}
/// Most read grants held at once; past it the oldest is dropped, so its next
/// read asks again.
const MAX_READ_GRANTS: usize = 64;
/// A read grant covers later reads of one entry by one verified consumer. The
/// purpose is free text, not an identity, so it is not part of the key.
struct Grant {
agent: String,
origin: Origin,
tier: AccessTier,
name: String,
/// Monotonic expiry in milliseconds.
expires_ms: u64,
}
impl Grant {
fn covers(&self, caller: &CallerContext, name: &str) -> bool {
self.agent == caller.agent
&& self.origin == caller.origin
&& self.tier == caller.tier
&& self.name == name
}
}
impl KeyringState {
fn granted(&self, caller: &CallerContext, name: &str, now: u64) -> BusResult<bool> {
let mut grants = self.grants.lock().map_err(|_| denied())?;
grants.retain(|grant| grant.expires_ms > now);
Ok(grants.iter().any(|grant| grant.covers(caller, name)))
}
fn grant(&self, caller: &CallerContext, name: &str, expires_ms: u64) -> BusResult<()> {
let mut grants = self.grants.lock().map_err(|_| denied())?;
grants.retain(|grant| !grant.covers(caller, name));
if grants.len() >= MAX_READ_GRANTS {
grants.remove(0);
}
grants.push(Grant {
agent: caller.agent.clone(),
origin: caller.origin.clone(),
tier: caller.tier.clone(),
name: name.to_owned(),
expires_ms,
});
Ok(())
}
/// End the grants for `name`, or every grant; reports whether any ended.
fn revoke(&self, name: Option<&str>) -> BusResult<bool> {
let mut grants = self.grants.lock().map_err(|_| denied())?;
let before = grants.len();
grants.retain(|grant| name.is_some_and(|name| grant.name != name));
Ok(grants.len() != before)
}
}
struct Deny;
impl NativePairingConsumer for Deny {
Expand Down Expand Up @@ -155,7 +212,10 @@ fn validate(config: &CryptoConfig2) -> BusResult<()> {
}
if let Some(keyring) = &config.keyring
&& (!control_free(&keyring.service, 128)
|| !(1..=300_000).contains(&keyring.consent_timeout_ms))
|| !(1..=300_000).contains(&keyring.consent_timeout_ms)
|| keyring
.read_grant
.is_some_and(|grant| !(1..=86_400_000).contains(&grant.max_ttl_ms)))
{
return Err(denied());
}
Expand Down Expand Up @@ -192,6 +252,11 @@ fn configure(
return Err(denied());
}
}
// The state outlives a reinit, so end every read grant here:
// the host decides again what it trusts.
if let Some(keyring) = &state.keyring {
keyring.revoke(None)?;
}
Ok(Some(state))
}
(None, None) => Ok(None),
Expand Down Expand Up @@ -252,6 +317,8 @@ fn build(config: &mut CryptoConfig2, init: &Initialization<'_>) -> BusResult<Bui
#[cfg(test)]
ordinary: false,
},
read_grant: keyring.read_grant,
grants: Mutex::default(),
})
})
.transpose()?;
Expand Down Expand Up @@ -386,6 +453,7 @@ impl PolicyService {
KeyringOperation2::Get { name } => Access::Get(name),
KeyringOperation2::Set { name, value } => Access::Set(name, Zeroizing::new(value)),
KeyringOperation2::Delete { name } => Access::Delete(name),
KeyringOperation2::RevokeGrants { name } => Access::Revoke(name),
};
keyring_inner(&state, &request, operation).await
}
Expand Down Expand Up @@ -469,12 +537,17 @@ async fn password_inner(
}
}

/// Ask the host; on `Allowed`, returns the clamped read-grant lifetime the
/// host chose, if this was a read it was offered on.
async fn consent(
keyring: &KeyringState,
request: &KeyringRequest2,
access: KeyringAccess2,
name: &str,
) -> BusResult<()> {
) -> BusResult<Option<u32>> {
let offer = keyring
.read_grant
.filter(|_| access == KeyringAccess2::Read);
let proxy = keyring
.consent
.connection
Expand All @@ -490,6 +563,7 @@ async fn consent(
access,
name: name.to_owned(),
timeout_ms: keyring.consent.timeout_ms,
offer,
},);
// The host serves KeyringConsent2 as a confidential callback endpoint and
// refuses an ordinary call.
Expand All @@ -509,7 +583,10 @@ async fn consent(
_ => consent_error(KeyringConsentOutcome2::Unavailable),
})?;
match reply.outcome {
KeyringConsentOutcome2::Allowed => Ok(()),
KeyringConsentOutcome2::Allowed => Ok(offer
.zip(reply.grant)
.map(|(offer, grant)| grant.ttl_ms.min(offer.max_ttl_ms))
.filter(|ttl| *ttl > 0)),
outcome => Err(consent_error(outcome)),
}
}
Expand All @@ -518,6 +595,7 @@ enum Access {
Get(String),
Set(String, Zeroizing<Vec<u8>>),
Delete(String),
Revoke(Option<String>),
}
async fn keyring_inner(
state: &Arc<CryptoState>,
Expand All @@ -537,11 +615,37 @@ async fn keyring_inner(
(KeyringAccess2::Write, name)
}
Access::Delete(name) => (KeyringAccess2::Delete, name),
Access::Revoke(name) => {
// Ending a grant needs no consent and is never rate-limited away.
if keyring.read_grant.is_none() {
return Err(denied());
}
if let Some(name) = name {
tinysecurity::crypto::secretstore::validate_name(name).map_err(|_| denied())?;
}
return Ok(KeyringResponse2 {
found: keyring.revoke(name.as_deref())?,
bytes: Vec::new(),
});
}
};
tinysecurity::crypto::secretstore::validate_name(name).map_err(|_| denied())?;
state.admit()?;
// Fresh human consent for exactly this operation precedes any backend access.
consent(keyring, request, access, name).await?;
let now = state.clock.monotonic_ms();
if access == KeyringAccess2::Read {
// A live grant for this consumer and entry stands in for the prompt.
if !keyring.granted(&request.caller, name, now)?
&& let Some(ttl) = consent(keyring, request, access, name).await?
{
let expires = now.checked_add(u64::from(ttl)).ok_or_else(denied)?;
keyring.grant(&request.caller, name, expires)?;
}
} else {
// A write or delete changes what a grant was given for: end it first,
// whatever the human then decides, and always ask.
keyring.revoke(Some(name))?;
consent(keyring, request, access, name).await?;
}
match &operation {
Access::Get(name) => {
let found = keyring.store.get(name).await.map_err(|_| denied())?;
Expand Down Expand Up @@ -576,6 +680,8 @@ async fn keyring_inner(
bytes: Vec::new(),
})
}
// Answered before any consent or backend access above.
Access::Revoke(_) => Err(denied()),
}
}

Expand Down
Loading
Loading