use std::collections::HashSet;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use crate::step_up::{
Attestation, CallRequest, Challenge, Decision, DischargeAttempt, DischargeProvider,
DischargeVerifier, StepUpPolicy,
};
use crate::{
AxisEnforcement, Caveats, CountBound, Sandbox, SandboxKind, Scope, Tool, ToolContext,
ToolError, ToolResult,
};
pub(crate) const DEFAULT_STRENGTH_FLOOR: AxisEnforcement = AxisEnforcement::Advisory;
pub(crate) const MAX_FRESHNESS_WINDOW: u64 = 4096;
pub struct Gate {
remaining: Option<AtomicU64>,
generation: u64,
sandbox_kind: SandboxKind,
strength_floor: AxisEnforcement,
consumed: Mutex<HashSet<[u8; 32]>>,
}
impl Gate {
#[must_use]
pub fn new(generation: u64) -> Self {
Self {
remaining: None,
generation,
sandbox_kind: SandboxKind::None,
strength_floor: DEFAULT_STRENGTH_FLOOR,
consumed: Mutex::new(HashSet::new()),
}
}
#[must_use]
pub fn with_budget(generation: u64, max_calls: CountBound) -> Self {
let remaining = match max_calls {
CountBound::Unlimited => None,
CountBound::AtMost(n) => Some(AtomicU64::new(n)),
};
Self {
remaining,
generation,
sandbox_kind: SandboxKind::None,
strength_floor: DEFAULT_STRENGTH_FLOOR,
consumed: Mutex::new(HashSet::new()),
}
}
#[must_use]
pub fn with_sandbox(mut self, sandbox: &dyn Sandbox) -> Self {
self.sandbox_kind = sandbox.kind();
self
}
#[must_use]
pub fn with_strength_floor(mut self, floor: AxisEnforcement) -> Self {
self.strength_floor = floor;
self
}
#[must_use]
pub fn generation(&self) -> u64 {
self.generation
}
pub fn authorize(&self, tool: &dyn Tool, granted: &Caveats) -> ToolResult<ToolContext> {
let effective = granted.meet(&tool.required());
self.check_generation(granted)?;
self.charge_one(granted)?;
Ok(ToolContext::mint(
effective,
self.sandbox_kind,
self.strength_floor,
))
}
fn check_generation(&self, granted: &Caveats) -> ToolResult<()> {
let ok = match &granted.valid_for_generation {
Scope::All => true,
Scope::Only(set) => set.contains(&self.generation),
};
if ok {
Ok(())
} else {
Err(ToolError::Generation)
}
}
fn charge_one(&self, granted: &Caveats) -> ToolResult<()> {
if let CountBound::AtMost(0) = granted.max_calls {
return Err(ToolError::Budget);
}
match &self.remaining {
None => Ok(()),
Some(counter) => {
loop {
let cur = counter.load(Ordering::Acquire);
if cur == 0 {
return Err(ToolError::Budget);
}
if counter
.compare_exchange_weak(cur, cur - 1, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
return Ok(());
}
}
}
}
}
}
impl Gate {
pub fn evaluate(
&self,
tool: &dyn Tool,
granted: &Caveats,
request: &CallRequest,
policy: &StepUpPolicy,
) -> Decision {
let effective = granted.meet(&tool.required());
if self.check_generation(granted).is_err() {
return Decision::Deny(ToolError::Generation);
}
let required = policy.required_for(request);
if required.demands_gesture() {
return Decision::NeedsDischarge(required);
}
match self.charge_one(granted) {
Ok(()) => Decision::Allow(ToolContext::mint(
effective,
self.sandbox_kind,
self.strength_floor,
)),
Err(e) => Decision::Deny(e),
}
}
pub fn authorize_with_discharge(
&self,
tool: &dyn Tool,
granted: &Caveats,
request: &CallRequest,
policy: &StepUpPolicy,
attempt: &DischargeAttempt,
) -> ToolResult<(ToolContext, Option<Attestation>)> {
let effective = granted.meet(&tool.required());
self.check_generation(granted)?;
let required = policy.required_for(request);
if !required.demands_gesture() {
self.charge_one(granted)?;
return Ok((
ToolContext::mint(effective, self.sandbox_kind, self.strength_floor),
None,
));
}
let content_id = request.content_id();
let window = required.freshness_generations.min(MAX_FRESHNESS_WINDOW);
let oldest = self.generation.saturating_sub(window);
let expected = (oldest..=self.generation)
.rev()
.map(|g| Challenge::bind(&content_id, g, &attempt.nonce))
.find(|c| c.as_bytes() == &attempt.discharge.challenge)
.unwrap_or_else(|| Challenge::bind(&content_id, self.generation, &attempt.nonce));
if let Err(reason) = attempt
.verifier
.verify(attempt.discharge, &required, &expected)
{
return Err(ToolError::denied(reason));
}
{
let mut consumed = self
.consumed
.lock()
.expect("step-up consumed-challenge ledger mutex poisoned");
if !consumed.insert(*expected.as_bytes()) {
return Err(ToolError::denied("discharge already consumed (replay)"));
}
}
let attestation = required.record.then(|| {
Attestation::from_verified(
&request.tool,
&request.resource,
attempt.discharge,
self.generation,
)
});
self.charge_one(granted)?;
Ok((
ToolContext::mint(effective, self.sandbox_kind, self.strength_floor),
attestation,
))
}
#[allow(clippy::too_many_arguments)]
pub fn authorize_step_up(
&self,
tool: &dyn Tool,
granted: &Caveats,
request: &CallRequest,
policy: &StepUpPolicy,
provider: &dyn DischargeProvider,
verifier: &dyn DischargeVerifier,
nonce: [u8; 32],
) -> ToolResult<(ToolContext, Option<Attestation>)> {
let required = policy.required_for(request);
if !required.demands_gesture() {
return self.authorize(tool, granted).map(|cx| (cx, None));
}
let discharge = provider
.obtain(request, &required, self.generation, &nonce)
.map_err(ToolError::denied)?;
let attempt = DischargeAttempt {
nonce,
discharge: &discharge,
verifier,
};
self.authorize_with_discharge(tool, granted, request, policy, &attempt)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ToolContext;
struct NoopTool {
required: Caveats,
}
#[async_trait::async_trait]
impl Tool for NoopTool {
fn name(&self) -> &str {
"noop"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({})
}
fn required(&self) -> Caveats {
self.required.clone()
}
async fn invoke(
&self,
_args: serde_json::Value,
_cx: &ToolContext,
) -> ToolResult<serde_json::Value> {
Ok(serde_json::Value::Null)
}
}
fn top_tool() -> NoopTool {
NoopTool {
required: Caveats::top(),
}
}
#[test]
fn strength_floor_defaults_advisory_and_rides_into_the_context() {
let cx = Gate::new(0)
.authorize(&top_tool(), &Caveats::top())
.expect("authorize");
assert_eq!(cx.strength_floor(), AxisEnforcement::Advisory);
let strong = Gate::new(0)
.with_strength_floor(AxisEnforcement::Kernel)
.authorize(&top_tool(), &Caveats::top())
.expect("authorize");
assert_eq!(strong.strength_floor(), AxisEnforcement::Kernel);
}
#[test]
fn effective_is_meet_and_leq_granted() {
let granted = Caveats {
exec: Scope::only(["echo".to_string(), "ls".to_string()]),
max_calls: CountBound::AtMost(5),
..Caveats::top()
};
let tool = NoopTool {
required: Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
},
};
let gate = Gate::new(0);
let cx = gate.authorize(&tool, &granted).unwrap();
assert_eq!(*cx.caveats(), granted.meet(&tool.required()));
assert!(cx.caveats().leq(&granted));
assert_eq!(cx.caveats().exec, Scope::only(["echo".to_string()]));
}
#[test]
fn effective_is_intersection_when_tool_declares_more_than_granted() {
let tool = NoopTool {
required: Caveats {
exec: Scope::only(["rm".to_string()]),
..Caveats::top()
},
};
let granted = Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
};
let gate = Gate::new(0);
let cx = gate.authorize(&tool, &granted).expect("authorize succeeds");
assert_eq!(cx.caveats().exec, Scope::none());
assert!(cx.check_exec("rm").is_err());
assert!(cx.check_exec("echo").is_err()); assert!(cx.caveats().leq(&granted));
}
#[test]
fn default_required_top_is_confined_by_grant() {
let granted = Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
};
let gate = Gate::new(0);
let cx = gate.authorize(&top_tool(), &granted).expect("authorize");
assert_eq!(*cx.caveats(), granted); }
#[test]
fn budget_at_most_two_allows_two_then_denies() {
let granted = Caveats::top();
let gate = Gate::with_budget(0, CountBound::AtMost(2));
assert!(gate.authorize(&top_tool(), &granted).is_ok());
assert!(gate.authorize(&top_tool(), &granted).is_ok());
let err = gate.authorize(&top_tool(), &granted).unwrap_err();
assert!(matches!(err, ToolError::Budget));
}
#[test]
fn grant_max_calls_zero_always_denied() {
let granted = Caveats {
max_calls: CountBound::AtMost(0),
..Caveats::top()
};
let gate = Gate::new(0);
assert!(matches!(
gate.authorize(&top_tool(), &granted).unwrap_err(),
ToolError::Budget
));
}
#[test]
fn generation_mismatch_denies() {
let gate = Gate::new(7);
let granted = Caveats {
valid_for_generation: Scope::only([3u64]),
..Caveats::top()
};
assert!(matches!(
gate.authorize(&top_tool(), &granted).unwrap_err(),
ToolError::Generation
));
let granted_ok = Caveats {
valid_for_generation: Scope::only([7u64]),
..Caveats::top()
};
assert!(gate.authorize(&top_tool(), &granted_ok).is_ok());
}
#[test]
fn denied_request_does_not_charge_budget() {
let gate = Gate::with_budget(7, CountBound::AtMost(1));
let bad = Caveats {
valid_for_generation: Scope::only([3u64]),
..Caveats::top()
};
assert!(gate.authorize(&top_tool(), &bad).is_err());
let good = Caveats::top();
assert!(gate.authorize(&top_tool(), &good).is_ok());
}
}