Skip to main content

active_rpc/
cap.rs

1//! Capabilities: unforgeable, attenuable references.
2//!
3//! A capability is an opaque 128-bit id minted by a kernel from a per-kernel
4//! secret, a monotonic serial and the parent id. Holding the id is the *only*
5//! authority needed to call the object it names, and the kernel is the only
6//! component that can mint one: an id that is not in the registry is refused
7//! with `rpc/unknown-capability`, so a peer cannot forge, guess or transplant a
8//! capability from another connection.
9//!
10//! Attenuation is monotone by construction: [`CapAttrs::attenuate`] refuses any
11//! attempt to widen methods, budget, path scope or tool policy.
12
13use std::collections::BTreeSet;
14use std::fmt;
15use std::rc::Rc;
16
17use crate::budget::Budget;
18use crate::error::{Error, Result};
19
20/// An unforgeable capability identifier.
21#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
22pub struct CapId(pub [u8; 16]);
23
24impl CapId {
25    /// Derive an id from the kernel secret, serial and parent.
26    ///
27    /// Deterministic for a given kernel (which makes tests and replays stable),
28    /// unpredictable to a peer without the secret.
29    pub fn derive(secret: &[u8; 32], serial: u64, parent: Option<&CapId>) -> CapId {
30        let mut hasher = blake3::Hasher::new();
31        hasher.update(b"active-rpc/cap");
32        hasher.update(secret);
33        hasher.update(&serial.to_le_bytes());
34        hasher.update(&parent.map(|id| id.0).unwrap_or([0u8; 16]));
35        let digest = hasher.finalize();
36        let mut id = [0u8; 16];
37        id.copy_from_slice(&digest.as_bytes()[..16]);
38        CapId(id)
39    }
40
41    /// Hex form for logs and errors.
42    ///
43    /// This is the raw capability id and **is** authority: possession of it is
44    /// enough to call the object while the kernel lives. Prefer
45    /// [`CapId::correlation_id`] in telemetry; `hex` is for an explicit,
46    /// deliberate transfer (the peer CLI prints it so a caller can use it).
47    pub fn hex(self) -> String {
48        let mut out = String::with_capacity(32);
49        for byte in self.0 {
50            out.push_str(&format!("{byte:02x}"));
51        }
52        out
53    }
54
55    /// A non-reversible correlation id for logs and events.
56    ///
57    /// Logs can correlate two events that mention the same capability without
58    /// carrying the authority itself.
59    pub fn correlation_id(self) -> String {
60        correlation(&self.0)
61    }
62}
63
64/// Domain-separated, truncated hash used for capability correlation.
65fn correlation(bytes: &[u8; 16]) -> String {
66    let mut hasher = blake3::Hasher::new();
67    hasher.update(b"active-rpc/capid-correlation");
68    hasher.update(bytes);
69    let digest = hasher.finalize();
70    let mut out = String::with_capacity(16);
71    for byte in &digest.as_bytes()[..8] {
72        out.push_str(&format!("{byte:02x}"));
73    }
74    out
75}
76
77/// A capability id carried by an error or diagnostic.
78///
79/// The raw 128 bits stay in memory so the kernel can look the id up, but every
80/// formatting path prints a correlation id. An error log therefore cannot be
81/// replayed as authority (I-3, I-4, H2.18).
82#[derive(Clone, Copy, PartialEq, Eq, Hash)]
83pub struct CapToken(pub u128);
84
85impl CapToken {
86    /// Correlation id matching [`CapId::correlation_id`] for the same id.
87    pub fn correlation_id(self) -> String {
88        correlation(&self.0.to_be_bytes())
89    }
90}
91
92impl fmt::Debug for CapToken {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        write!(f, "CapToken({})", self.correlation_id())
95    }
96}
97
98impl fmt::Display for CapToken {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        f.write_str(&self.correlation_id())
101    }
102}
103
104impl fmt::Debug for CapId {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        // Default telemetry must not carry raw authority bits. Use `hex` only
107        // for a deliberate transfer.
108        write!(f, "CapId({})", self.correlation_id())
109    }
110}
111
112impl fmt::Display for CapId {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        // Display is a diagnostic surface, like Debug. Raw authority is only
115        // available through the explicitly documented `hex()`.
116        f.write_str(&self.correlation_id())
117    }
118}
119
120/// Identity of a provider object: which adapter, which object within it.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
122pub struct ProviderId(pub u64);
123
124/// Tool-level policy attached to a capability.
125#[derive(Debug, Clone, PartialEq, Eq, Default)]
126pub struct ToolPolicy {
127    /// Tool names this capability may invoke. Empty means "none".
128    pub allowed: BTreeSet<String>,
129    /// Maximum bytes one tool call may pass.
130    pub max_arg_bytes: u64,
131    /// Whether the tool may run a local process.
132    pub allow_process: bool,
133}
134
135impl ToolPolicy {
136    /// Build a policy from tool names.
137    pub fn allow(names: &[&str]) -> Self {
138        Self {
139            allowed: names.iter().map(|name| name.to_string()).collect(),
140            max_arg_bytes: 1 << 20,
141            allow_process: false,
142        }
143    }
144
145    /// True when `self` grants nothing beyond `parent`.
146    pub fn is_attenuation_of(&self, parent: &ToolPolicy) -> bool {
147        self.allowed.is_subset(&parent.allowed)
148            && self.max_arg_bytes <= parent.max_arg_bytes
149            && (!self.allow_process || parent.allow_process)
150    }
151}
152
153/// The authority a capability carries.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct CapAttrs {
156    /// Granted method names. Must be non-empty after attenuation.
157    pub methods: BTreeSet<String>,
158    /// Resource budget.
159    pub budget: Budget,
160    /// May not mutate module state.
161    pub read_only: bool,
162    /// Redacts trace output.
163    pub redact: bool,
164    /// Dotted path prefix the capability is confined to (inclusive).
165    pub path_scope: Option<String>,
166    /// Tool policy, when the capability targets a tool.
167    pub tool_policy: Option<ToolPolicy>,
168}
169
170impl CapAttrs {
171    /// Full authority over `methods` with the default budget.
172    pub fn new(methods: &[&str]) -> Self {
173        Self {
174            methods: methods.iter().map(|method| method.to_string()).collect(),
175            budget: Budget::default(),
176            read_only: false,
177            redact: false,
178            path_scope: None,
179            tool_policy: None,
180        }
181    }
182
183    /// Restrict methods.
184    pub fn with_methods(mut self, methods: &[&str]) -> Self {
185        self.methods = methods.iter().map(|method| method.to_string()).collect();
186        self
187    }
188
189    /// Restrict the budget.
190    pub fn with_budget(mut self, budget: Budget) -> Self {
191        self.budget = budget;
192        self
193    }
194
195    /// Confine the capability to a path prefix.
196    pub fn with_path(mut self, path: impl Into<String>) -> Self {
197        self.path_scope = Some(path.into());
198        self
199    }
200
201    /// Mark the capability read-only.
202    pub fn read_only(mut self) -> Self {
203        self.read_only = true;
204        self
205    }
206
207    /// Mark the capability redacting.
208    pub fn redacted(mut self) -> Self {
209        self.redact = true;
210        self
211    }
212
213    /// Attach a tool policy.
214    pub fn with_tool_policy(mut self, policy: ToolPolicy) -> Self {
215        self.tool_policy = Some(policy);
216        self
217    }
218
219    /// True when `self` is a valid attenuation of `parent`.
220    pub fn is_attenuation_of(&self, parent: &CapAttrs) -> bool {
221        let methods_ok = self.methods.is_subset(&parent.methods) && !self.methods.is_empty();
222        let budget_ok = self.budget.is_attenuation_of(&parent.budget);
223        let read_only_ok = !parent.read_only || self.read_only;
224        let redact_ok = !parent.redact || self.redact;
225        let path_ok = match (&self.path_scope, &parent.path_scope) {
226            (None, None) => true,
227            (Some(_), None) => true,
228            (Some(child), Some(parent)) => is_path_prefix(parent, child),
229            (None, Some(_)) => false,
230        };
231        let tool_ok = match (&self.tool_policy, &parent.tool_policy) {
232            // A parent without a policy leaves the tool surface open; adding one
233            // on the child narrows it, which `attenuate` also accepts.
234            (_, None) => true,
235            // Dropping the parent's policy widens authority.
236            (None, Some(_)) => false,
237            (Some(policy), Some(parent)) => policy.is_attenuation_of(parent),
238        };
239        methods_ok && budget_ok && read_only_ok && redact_ok && path_ok && tool_ok
240    }
241
242    /// Attenuate, refusing anything that would widen authority.
243    pub fn attenuate(&self, child: CapAttrs) -> Result<CapAttrs> {
244        if child.methods.is_empty() {
245            return Err(Error::InvalidArgument("attenuation grants no methods"));
246        }
247        if !child.methods.is_subset(&self.methods) {
248            return Err(Error::Escalation("method set grows"));
249        }
250        if !child.budget.is_attenuation_of(&self.budget) {
251            return Err(Error::Escalation("budget grows"));
252        }
253        if self.read_only && !child.read_only {
254            return Err(Error::Escalation("read-only cannot be lifted"));
255        }
256        if self.redact && !child.redact {
257            return Err(Error::Escalation("redaction cannot be lifted"));
258        }
259        match (&child.path_scope, &self.path_scope) {
260            (_, None) => {}
261            (Some(child_path), Some(parent)) if is_path_prefix(parent, child_path) => {}
262            (Some(_), Some(_)) => return Err(Error::Escalation("path scope widens")),
263            (None, Some(_)) => return Err(Error::Escalation("path scope removed")),
264        }
265        match (&child.tool_policy, &self.tool_policy) {
266            (_, None) => {}
267            (Some(policy), Some(parent)) if policy.is_attenuation_of(parent) => {}
268            _ => return Err(Error::Escalation("tool policy widens")),
269        }
270        let mut attrs = child;
271        attrs.budget = attrs.budget.intersect(&self.budget);
272        Ok(attrs)
273    }
274
275    /// Whether `method` is granted.
276    pub fn grants(&self, method: &str) -> bool {
277        self.methods.contains(method)
278    }
279
280    /// Whether `path` is inside the capability's scope.
281    pub fn covers_path(&self, path: &str) -> bool {
282        match &self.path_scope {
283            None => true,
284            Some(scope) => is_path_prefix(scope, path),
285        }
286    }
287}
288
289/// True when `parent` is a dotted-path prefix of `child` (or equal).
290pub fn is_path_prefix(parent: &str, child: &str) -> bool {
291    if parent == child {
292        return true;
293    }
294    child
295        .strip_prefix(parent)
296        .map(|rest| rest.starts_with('.'))
297        .unwrap_or(false)
298}
299
300/// Provider-side record of a live capability.
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct CapRecord {
303    /// Capability id.
304    pub id: CapId,
305    /// Provider that owns the object.
306    pub provider: ProviderId,
307    /// Object within the provider (module id, tool id, lane id).
308    pub object: u64,
309    /// Authority.
310    pub attrs: CapAttrs,
311    /// Parent capability, when this one was derived by attenuation.
312    pub parent: Option<CapId>,
313    /// Outstanding references (client handles plus transfers).
314    pub refs: u32,
315    /// Revoked capabilities are refused even if the id is known.
316    pub revoked: bool,
317}
318
319impl CapRecord {
320    /// A first-generation capability.
321    pub fn new(id: CapId, provider: ProviderId, object: u64, attrs: CapAttrs) -> Self {
322        Self {
323            id,
324            provider,
325            object,
326            attrs,
327            parent: None,
328            refs: 1,
329            revoked: false,
330        }
331    }
332}
333
334/// Control surface a capability handle uses to release its reference.
335///
336/// The kernel implements this; `cap.rs` stays independent of the kernel so the
337/// two can be reasoned about separately.
338pub trait CapControl {
339    /// Add one reference to a capability.
340    fn retain(&self, id: CapId);
341    /// Drop one reference; the provider is notified when the last one goes.
342    fn release(&self, id: CapId);
343}
344
345/// A client-side capability handle.
346///
347/// Cloning a handle adds a reference; dropping the last handle notifies the
348/// provider (automatic lifetime, `RPROPOSAL` §3).
349pub struct CapHandle {
350    control: Rc<dyn CapControl>,
351    id: CapId,
352    /// False only for handles that do not own a reference (imported views).
353    owning: bool,
354}
355
356impl CapHandle {
357    /// Wrap a minted capability.
358    pub fn new(control: Rc<dyn CapControl>, id: CapId) -> Self {
359        Self {
360            control,
361            id,
362            owning: true,
363        }
364    }
365
366    /// A non-owning handle (used when reconstructing a handle from a transfer).
367    pub fn borrowed(control: Rc<dyn CapControl>, id: CapId) -> Self {
368        Self {
369            control,
370            id,
371            owning: false,
372        }
373    }
374
375    /// The capability id this handle refers to.
376    pub fn id(&self) -> CapId {
377        self.id
378    }
379
380    /// Surrender this handle without releasing its reference.
381    ///
382    /// Used when a reference moves (channel transfer): the receiver becomes
383    /// responsible for the reference the sender held.
384    pub fn into_id(mut self) -> CapId {
385        self.owning = false;
386        self.id
387    }
388}
389
390impl Clone for CapHandle {
391    /// Cloning an *owning* handle retains the capability: the kernel increments
392    /// the reference count and both handles must drop before the provider is
393    /// notified. Cloning a borrowed handle stays borrowed — it never retained a
394    /// reference, so it must not create one.
395    fn clone(&self) -> Self {
396        if self.owning {
397            self.control.retain(self.id);
398        }
399        Self {
400            control: Rc::clone(&self.control),
401            id: self.id,
402            owning: self.owning,
403        }
404    }
405}
406
407impl Drop for CapHandle {
408    fn drop(&mut self) {
409        if self.owning {
410            self.control.release(self.id);
411        }
412    }
413}
414
415impl fmt::Debug for CapHandle {
416    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417        f.debug_struct("CapHandle").field("id", &self.id).finish()
418    }
419}