From a34ae0a7cfb68c4c8e9728c2661e6c43135d400d Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 15:27:41 +0530 Subject: [PATCH] feat(keyring): opt-in read grants for repeated keyring reads Every keyring operation asked the host for a fresh consent, so a host that reads the same entry often prompted again and again, or cached the plaintext itself where the module could not see or revoke it. A host can now set KeyringConfig2.read_grant { max_ttl_ms } (at most 24 h). A read's consent request then carries an offer, and an Allowed reply may carry grant { ttl_ms }. The module clamps it and lets later reads of the same entry by the same agent, origin and tier skip the prompt. Grants live only in module memory, at most 64 with the oldest dropped first, and reads under one are still rate-admitted. Writes and deletes are never offered a grant and always ask. A write or delete of the entry, a reinit, the new RevokeGrants operation (one entry, or all) and process exit each end a grant before any backend access. Without read_grant nothing changes on the wire. --- crates/tinysecurity-bus/src/crypto_ops.rs | 39 ++- .../tinysecurity-bus/src/crypto_ops_tests.rs | 51 ++++ .../examples/keyring_native_contract.rs | 2 + .../tinysecurity-module/src/adapter_crypto.rs | 126 +++++++- .../src/adapter_crypto_tests.rs | 277 +++++++++++++++++- docs/module-crypto.md | 11 + 6 files changed, 494 insertions(+), 12 deletions(-) diff --git a/crates/tinysecurity-bus/src/crypto_ops.rs b/crates/tinysecurity-bus/src/crypto_ops.rs index f9ca6d00..b03467e6 100644 --- a/crates/tinysecurity-bus/src/crypto_ops.rs +++ b/crates/tinysecurity-bus/src/crypto_ops.rs @@ -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 { @@ -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, +} + +/// 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. @@ -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, + }, } /// Keyring request with verified caller facts for the consent prompt. @@ -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, } /// Outcome from the host's keyring consent decision. @@ -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, } /// Pairing operation on a registered account. diff --git a/crates/tinysecurity-bus/src/crypto_ops_tests.rs b/crates/tinysecurity-bus/src/crypto_ops_tests.rs index ba9c29da..cf5e4076 100644 --- a/crates/tinysecurity-bus/src/crypto_ops_tests.rs +++ b/crates/tinysecurity-bus/src/crypto_ops_tests.rs @@ -74,6 +74,7 @@ fn config_round_trips_and_omits_absent_sections() -> Result<(), Box Result<(), Box> { + 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(()) +} diff --git a/crates/tinysecurity-module/examples/keyring_native_contract.rs b/crates/tinysecurity-module/examples/keyring_native_contract.rs index 531cd7a7..05c4adac 100644 --- a/crates/tinysecurity-module/examples/keyring_native_contract.rs +++ b/crates/tinysecurity-module/examples/keyring_native_contract.rs @@ -37,6 +37,7 @@ impl Consenter { } else { KeyringConsentOutcome2::ConsentDenied }, + grant: None, })) .await } @@ -120,6 +121,7 @@ async fn main() -> Result<(), Box> { directory: store.path().canonicalize()?.to_string_lossy().into_owned(), }, consent_timeout_ms: 5000, + read_grant: None, }), pairing: None, }), diff --git a/crates/tinysecurity-module/src/adapter_crypto.rs b/crates/tinysecurity-module/src/adapter_crypto.rs index 87230965..6549ee91 100644 --- a/crates/tinysecurity-module/src/adapter_crypto.rs +++ b/crates/tinysecurity-module/src/adapter_crypto.rs @@ -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; @@ -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; @@ -65,6 +67,61 @@ struct Consent { struct KeyringState { store: Arc, consent: Consent, + /// Host opt-in; absent, every operation asks. + read_grant: Option, + /// Live read grants, oldest first; memory only, so a restart or reinit + /// (which builds a new state) ends them all. + grants: Mutex>, +} +/// 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 { + 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 { + 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 { @@ -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()); } @@ -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), @@ -252,6 +317,8 @@ fn build(config: &mut CryptoConfig2, init: &Initialization<'_>) -> BusResult 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 } @@ -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> { + let offer = keyring + .read_grant + .filter(|_| access == KeyringAccess2::Read); let proxy = keyring .consent .connection @@ -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. @@ -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)), } } @@ -518,6 +595,7 @@ enum Access { Get(String), Set(String, Zeroizing>), Delete(String), + Revoke(Option), } async fn keyring_inner( state: &Arc, @@ -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())?; @@ -576,6 +680,8 @@ async fn keyring_inner( bytes: Vec::new(), }) } + // Answered before any consent or backend access above. + Access::Revoke(_) => Err(denied()), } } diff --git a/crates/tinysecurity-module/src/adapter_crypto_tests.rs b/crates/tinysecurity-module/src/adapter_crypto_tests.rs index 4f4306e1..75831c69 100644 --- a/crates/tinysecurity-module/src/adapter_crypto_tests.rs +++ b/crates/tinysecurity-module/src/adapter_crypto_tests.rs @@ -53,6 +53,8 @@ impl Drop for Fixture { #[derive(Clone)] struct Consenter { allow: Arc, + /// Read grant returned with every answer, in milliseconds; 0 returns none. + grant: Arc, seen: Arc>>, } #[tinybus::interface(name = "ai.tinyhumans.tinysecurity.KeyringConsent2")] @@ -64,12 +66,14 @@ impl Consenter { // The prompt carries the secret name but never a value. assert!(!serde_json::to_string(&request)?.contains("private-value")); self.seen.lock().map_err(|_| denied())?.push(request); + let ttl_ms = self.grant.load(std::sync::atomic::Ordering::SeqCst); std::future::ready(Ok(KeyringConsentResponse2 { outcome: if self.allow.load(std::sync::atomic::Ordering::SeqCst) { KeyringConsentOutcome2::Allowed } else { KeyringConsentOutcome2::ConsentDenied }, + grant: (ttl_ms > 0).then_some(tinysecurity_bus::KeyringGrant2 { ttl_ms }), })) .await } @@ -85,6 +89,7 @@ fn config(dir: &std::path::Path) -> CryptoConfig2 { directory: dir.to_string_lossy().into_owned(), }, consent_timeout_ms: 1000, + read_grant: None, }), pairing: Some(tinysecurity_bus::PairingConfig2 { accounts: vec![ @@ -112,6 +117,20 @@ fn config(dir: &std::path::Path) -> CryptoConfig2 { } async fn fixture(allow: bool) -> Result<(Fixture, Consenter), Box> { + fixture_with( + allow, + None, + Arc::new(tinysecurity::approval::SystemClock::default()), + ) + .await +} + +/// A fixture whose host configured `read_grant` and whose clock the test steps. +async fn fixture_with( + allow: bool, + read_grant: Option, + clock: Arc, +) -> Result<(Fixture, Consenter), Box> { use tinybus::{Connection, broker::Broker, transport::memory::MemoryBus}; let bus = MemoryBus::new(); let broker = Broker::new().spawn(bus.clone()).abort_handle(); @@ -125,7 +144,16 @@ async fn fixture(allow: bool) -> Result<(Fixture, Consenter), Box Result<(Fixture, Consenter), Box KeyringRequest2 { + keyring_request(KeyringOperation2::Get { name: name.into() }) +} +fn prompts(consenter: &Consenter) -> Result> { + Ok(consenter.seen.lock().map_err(|_| "seen")?.len()) +} +/// A fixture with read grants of at most `max_ttl_ms`, a host that answers +/// every consent with `grant_ms`, and a clock the test steps. +async fn granting( + max_ttl_ms: u32, + grant_ms: u32, +) -> Result<(Fixture, Consenter, Arc), Box> { + let clock = Arc::new(StepClock::default()); + let (fixture, consenter) = + fixture_with(true, Some(KeyringReadGrant2 { max_ttl_ms }), clock.clone()).await?; + consenter + .grant + .store(grant_ms, std::sync::atomic::Ordering::SeqCst); + Ok((fixture, consenter, clock)) +} + +#[tokio::test] +async fn a_read_grant_covers_repeat_reads_until_its_clamped_expiry() +-> Result<(), Box> { + // The host asks for 5 s; the module holds it to the 1 s ceiling. + let (fixture, consenter, clock) = granting(1_000, 5_000).await?; + fixture.service.keyring_access(get("user:token")).await?; + { + let seen = consenter.seen.lock().map_err(|_| "seen")?; + assert_eq!(seen[0].offer, Some(KeyringReadGrant2 { max_ttl_ms: 1_000 })); + } + clock.0.store(999, std::sync::atomic::Ordering::SeqCst); + fixture.service.keyring_access(get("user:token")).await?; + assert_eq!(prompts(&consenter)?, 1); + clock.0.store(1_000, std::sync::atomic::Ordering::SeqCst); + fixture.service.keyring_access(get("user:token")).await?; + assert_eq!(prompts(&consenter)?, 2); + Ok(()) +} + +#[tokio::test] +async fn a_read_grant_is_bound_to_the_entry_agent_origin_and_tier() +-> Result<(), Box> { + let (fixture, consenter, _) = granting(60_000, 60_000).await?; + fixture.service.keyring_access(get("user:token")).await?; + let mut other_agent = get("user:token"); + other_agent.caller.agent = "other".into(); + let mut other_origin = get("user:token"); + other_origin.caller.origin = tinysecurity_bus::Origin::Cron; + let mut other_tier = get("user:token"); + other_tier.caller.tier = tinysecurity_bus::AccessTier::Write; + let mut other_purpose = get("user:token"); + other_purpose.purpose = "a different purpose".into(); + for request in [ + get("user:other"), + other_agent, + other_origin, + other_tier, + other_purpose, + ] { + fixture.service.keyring_access(request).await?; + } + // Every request but the purpose change asked again: purpose is not bound. + assert_eq!(prompts(&consenter)?, 5); + Ok(()) +} + +#[tokio::test] +async fn writes_and_deletes_always_ask_and_end_the_entrys_grant() +-> Result<(), Box> { + let (fixture, consenter, _) = granting(60_000, 60_000).await?; + let set = || { + keyring_request(KeyringOperation2::Set { + name: "user:token".into(), + value: b"private-value".to_vec(), + }) + }; + let delete = || { + keyring_request(KeyringOperation2::Delete { + name: "user:token".into(), + }) + }; + fixture.service.keyring_access(set()).await?; + fixture.service.keyring_access(get("user:token")).await?; + fixture.service.keyring_access(set()).await?; + fixture.service.keyring_access(get("user:token")).await?; + fixture.service.keyring_access(delete()).await?; + fixture.service.keyring_access(get("user:token")).await?; + { + let seen = consenter.seen.lock().map_err(|_| "seen")?; + let access: Vec<_> = seen.iter().map(|request| request.access).collect(); + assert_eq!( + access, + [ + KeyringAccess2::Write, + KeyringAccess2::Read, + KeyringAccess2::Write, + KeyringAccess2::Read, + KeyringAccess2::Delete, + KeyringAccess2::Read, + ] + ); + // Only reads are offered a grant. + assert!( + seen.iter() + .all(|r| r.offer.is_some() == (r.access == KeyringAccess2::Read)) + ); + } + // A refused write still ends the grant. + consenter + .allow + .store(false, std::sync::atomic::Ordering::SeqCst); + assert!(fixture.service.keyring_access(set()).await.is_err()); + consenter + .allow + .store(true, std::sync::atomic::Ordering::SeqCst); + fixture.service.keyring_access(get("user:token")).await?; + assert_eq!(prompts(&consenter)?, 8); + Ok(()) +} + +#[tokio::test] +async fn revoke_grants_ends_one_entry_or_all_without_consent() +-> Result<(), Box> { + let (fixture, consenter, _) = granting(60_000, 60_000).await?; + let revoke = |name: Option<&str>| { + keyring_request(KeyringOperation2::RevokeGrants { + name: name.map(str::to_owned), + }) + }; + fixture.service.keyring_access(get("user:a")).await?; + fixture.service.keyring_access(get("user:b")).await?; + assert!( + fixture + .service + .keyring_access(revoke(Some("user:a"))) + .await? + .found + ); + assert!( + !fixture + .service + .keyring_access(revoke(Some("user:a"))) + .await? + .found + ); + assert_eq!(prompts(&consenter)?, 2); + fixture.service.keyring_access(get("user:a")).await?; + fixture.service.keyring_access(get("user:b")).await?; + assert_eq!(prompts(&consenter)?, 3); + assert!(fixture.service.keyring_access(revoke(None)).await?.found); + fixture.service.keyring_access(get("user:a")).await?; + fixture.service.keyring_access(get("user:b")).await?; + assert_eq!(prompts(&consenter)?, 5); + // A bad name is refused like any other keyring name. + assert!( + fixture + .service + .keyring_access(revoke(Some(""))) + .await + .is_err() + ); + Ok(()) +} + +#[tokio::test] +async fn reinit_ends_every_read_grant() -> Result<(), Box> { + let (fixture, consenter, _) = granting(60_000, 60_000).await?; + fixture.service.keyring_access(get("user:token")).await?; + let state = fixture.service.crypto.as_ref().ok_or("state")?; + let current = Some(Arc::downgrade(state)); + let init = initialization(&fixture.module, ""); + let same = configure(None, Some(¤t), &init)?.ok_or("state")?; + assert!(Arc::ptr_eq(&same, state)); + fixture.service.keyring_access(get("user:token")).await?; + assert_eq!(prompts(&consenter)?, 2); + Ok(()) +} + +#[tokio::test] +async fn read_grants_are_capped_and_the_oldest_is_dropped_first() +-> Result<(), Box> { + let (fixture, consenter, _) = granting(60_000, 60_000).await?; + for index in 0..=MAX_READ_GRANTS { + fixture + .service + .keyring_access(get(&format!("user:n{index}"))) + .await?; + } + let granted = MAX_READ_GRANTS + 1; + assert_eq!(prompts(&consenter)?, granted); + // The newest is still covered; the first was dropped to make room. + fixture + .service + .keyring_access(get(&format!("user:n{MAX_READ_GRANTS}"))) + .await?; + assert_eq!(prompts(&consenter)?, granted); + fixture.service.keyring_access(get("user:n0")).await?; + assert_eq!(prompts(&consenter)?, granted + 1); + Ok(()) +} + +#[tokio::test] +async fn without_read_grant_a_host_grant_is_ignored_and_nothing_is_offered() +-> Result<(), Box> { + let (fixture, consenter) = fixture(true).await?; + consenter + .grant + .store(60_000, std::sync::atomic::Ordering::SeqCst); + fixture.service.keyring_access(get("user:token")).await?; + fixture.service.keyring_access(get("user:token")).await?; + { + let seen = consenter.seen.lock().map_err(|_| "seen")?; + assert_eq!(seen.len(), 2); + assert!(seen.iter().all(|request| request.offer.is_none())); + assert!(!serde_json::to_string(&seen[0])?.contains("offer")); + } + // Revoking needs the opt-in too. + assert!( + fixture + .service + .keyring_access(keyring_request(KeyringOperation2::RevokeGrants { + name: None + })) + .await + .is_err() + ); + Ok(()) +} + +#[tokio::test] +async fn a_zero_grant_grants_nothing() -> Result<(), Box> { + let (fixture, consenter, _) = granting(60_000, 0).await?; + fixture.service.keyring_access(get("user:token")).await?; + fixture.service.keyring_access(get("user:token")).await?; + assert_eq!(prompts(&consenter)?, 2); + Ok(()) +} diff --git a/docs/module-crypto.md b/docs/module-crypto.md index 0d615861..dee2b653 100644 --- a/docs/module-crypto.md +++ b/docs/module-crypto.md @@ -197,6 +197,17 @@ operation first calls the host's `ai.tinyhumans.tinysecurity.KeyringConsent2` (never the value), and proceeds only on an explicit allow. Invalid names or purposes, oversized values and denied or timed-out consent never reach the backend. +A host can opt in to read grants with `KeyringConfig2.read_grant { max_ttl_ms }` +(at most 24 hours). Absent, nothing changes on the wire. With it, a read's consent +request carries `offer`, and an `Allowed` reply may carry `grant { ttl_ms }`. The +module clamps the lifetime to the ceiling and then lets later reads of the same +entry by the same agent, origin and tier skip the prompt. The purpose is not part +of the key. Grants live only in module memory, at most 64 at a time, with the +oldest dropped first. Granted reads are still rate-admitted. Writes and deletes are +never offered a grant and always ask. A write or delete of the entry, a reinit, a +`RevokeGrants` operation (one entry, or all) and process exit each end the grant, +before any backend access. + `PairingControl2` exposes `Status`, `Authenticate` (constant-time bearer check against legacy tokens), `Invalidate` and `Revoke` for accounts registered at init. Challenge issuance and numeric verification (`Begin`/`Verify`) are not exposed: