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: