Skip to main content

byteflow/bytecode/
cap.rs

1//! Capability model — Phase 3.
2//!
3//! A [`CapId`] is still the bytecode-visible token. Authorization lives in a
4//! [`Cap`] record (target + rights + optional native mask + epoch).
5//!
6//! Design:
7//! - Rights are a fixed bitset (no dynamic strings): cheap to check, cheap
8//!   to attenuate, easy to audit.
9//! - Every Cap carries an `epoch`. One revocation counter per issuer
10//!   (flow, native table, scheduler) invalidates every derived Cap in O(1)
11//!   without scanning the live-flow table.
12//! - [`Cap::attenuate`] is the **only** way to derive a new Cap from an
13//!   existing one. There is no constructor that lets a flow synthesize
14//!   rights it does not already hold — that is where S7 closes: a
15//!   [`NativeMask`] only shrinks, never grows, on any code path.
16
17use std::fmt;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::Arc;
20
21/// Unpredictable capability token. Never a flow id.
22///
23/// `0` is reserved as [`CapId::NONE`] (“no grant”, e.g. host hops without a
24/// reply address). Minted ids are never zero.
25#[derive(Clone, Copy, PartialEq, Eq, Hash)]
26pub struct CapId(u128);
27
28/// Why [`CapId::random`] could not produce a token.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum CapIdError {
31    /// Operating-system CSPRNG failed.
32    Entropy,
33}
34
35impl CapId {
36    /// Placeholder used on unauthenticated / host-injected hops.
37    pub const NONE: CapId = CapId(0);
38
39    /// Draw a non-zero id from the OS CSPRNG. Never panics.
40    pub fn random() -> Result<Self, CapIdError> {
41        for _ in 0..8 {
42            let mut bytes = [0u8; 16];
43            if getrandom::getrandom(&mut bytes).is_err() {
44                return Err(CapIdError::Entropy);
45            }
46            let raw = u128::from_le_bytes(bytes);
47            if raw != 0 {
48                return Ok(CapId(raw));
49            }
50        }
51        Err(CapIdError::Entropy)
52    }
53
54    #[inline]
55    pub const fn is_none(self) -> bool {
56        self.0 == 0
57    }
58
59    #[inline]
60    pub const fn as_u128(self) -> u128 {
61        self.0
62    }
63
64    /// Wire / trusted-decode path only. Does **not** insert into any table.
65    #[inline]
66    pub(crate) const fn from_raw(raw: u128) -> Self {
67        CapId(raw)
68    }
69}
70
71impl fmt::Debug for CapId {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        write!(f, "CapId({:032x})", self.0)
74    }
75}
76
77impl fmt::Display for CapId {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        if self.is_none() {
80            f.write_str("cap#none")
81        } else {
82            write!(f, "cap#{:032x}", self.0)
83        }
84    }
85}
86
87impl std::error::Error for CapIdError {}
88
89impl fmt::Display for CapIdError {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self {
92            CapIdError::Entropy => f.write_str("capability CSPRNG unavailable"),
93        }
94    }
95}
96
97/// Index into a [`crate::NativeTable`].
98pub type NativeIdx = u32;
99
100/// Fixed-width rights bitset. Keep this an explicit allow-list: every new
101/// bit here must be audited against the threat-model doc before production.
102///
103/// Low two bits stay `SEND` / `ASK` so ABI-era 0.9 addressing tokens remain
104/// compatible (`SEND_ASK == 0b11`).
105#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
106pub struct CapRights(u32);
107
108impl CapRights {
109    pub const NONE: CapRights = CapRights(0);
110    pub const SEND: CapRights = CapRights(1 << 0);
111    pub const ASK: CapRights = CapRights(1 << 1);
112    pub const RECV: CapRights = CapRights(1 << 2);
113    pub const SPAWN: CapRights = CapRights(1 << 3);
114    pub const LINK: CapRights = CapRights(1 << 4);
115    pub const MONITOR: CapRights = CapRights(1 << 5);
116    pub const ADMIN: CapRights = CapRights(1 << 6);
117    pub const NATIVE: CapRights = CapRights(1 << 7);
118
119    /// Addressing grant minted on `SelfPid` / `Spawn` / `grant_cap`.
120    pub const ADDRESSING: CapRights = CapRights(
121        Self::SEND.0 | Self::ASK.0 | Self::LINK.0 | Self::MONITOR.0,
122    );
123
124    /// Default child request from the high-level assembler (`Fn::spawn`).
125    /// Does **not** include `ADMIN`. The raw opcode with `c = 0` is confined
126    /// (`NONE`); the assembler writes this mask so existing actor samples
127    /// keep working without an implicit second grant path.
128    pub const FLOW: CapRights = CapRights(
129        Self::SEND.0
130            | Self::ASK.0
131            | Self::RECV.0
132            | Self::SPAWN.0
133            | Self::LINK.0
134            | Self::MONITOR.0
135            | Self::NATIVE.0,
136    );
137
138    /// Host-spawned root authority (init). Same as [`Self::FLOW`] — `ADMIN`
139    /// is never in the default set; the embedder mints it explicitly.
140    pub const ROOT: CapRights = Self::FLOW;
141
142    /// Historical alias: `SEND | ASK`.
143    pub const SEND_ASK: CapRights = CapRights(Self::SEND.0 | Self::ASK.0);
144
145    #[inline]
146    pub const fn empty() -> Self {
147        CapRights(0)
148    }
149
150    #[inline]
151    pub const fn union(self, other: CapRights) -> CapRights {
152        CapRights(self.0 | other.0)
153    }
154
155    #[inline]
156    pub const fn intersect(self, other: CapRights) -> CapRights {
157        CapRights(self.0 & other.0)
158    }
159
160    #[inline]
161    pub const fn contains(self, other: CapRights) -> bool {
162        self.0 & other.0 == other.0
163    }
164
165    #[inline]
166    pub const fn is_subset_of(self, other: CapRights) -> bool {
167        self.0 & !other.0 == 0
168    }
169
170    #[inline]
171    pub const fn bits(self) -> u32 {
172        self.0
173    }
174
175    /// Low 8 bits for the `Spawn` / `Delegate` immediate operand.
176    #[inline]
177    pub const fn bits_u8(self) -> u8 {
178        self.0 as u8
179    }
180
181    #[inline]
182    pub const fn from_bits(bits: u32) -> Self {
183        CapRights(bits)
184    }
185
186    #[inline]
187    pub const fn from_u8(bits: u8) -> Self {
188        CapRights(bits as u32)
189    }
190}
191
192/// Bitset over `NativeTable` indices, sized once at boot to
193/// `NativeTable::len()`. Shared via `Arc` because attenuation clones the Cap
194/// far more often than it clones the mask.
195#[derive(Clone, Debug, PartialEq, Eq)]
196pub struct NativeMask(Arc<[u64]>);
197
198impl NativeMask {
199    pub fn empty(native_count: usize) -> Self {
200        let words = native_count.div_ceil(64);
201        Self(Arc::from(vec![0u64; words].into_boxed_slice()))
202    }
203
204    pub fn full(native_count: usize) -> Self {
205        let words = native_count.div_ceil(64);
206        let mut v = vec![u64::MAX; words];
207        // Clear tail bits above `native_count`, otherwise `intersect` is
208        // imprecise near the upper bound.
209        let rem = native_count % 64;
210        if rem != 0 {
211            if let Some(last) = v.last_mut() {
212                *last &= (1u64 << rem) - 1;
213            }
214        }
215        Self(Arc::from(v.into_boxed_slice()))
216    }
217
218    pub fn from_indices(native_count: usize, idxs: &[NativeIdx]) -> Self {
219        let words = native_count.div_ceil(64);
220        let mut v = vec![0u64; words];
221        for &i in idxs {
222            let (w, b) = (i as usize / 64, i as usize % 64);
223            if w < v.len() && (i as usize) < native_count {
224                v[w] |= 1u64 << b;
225            }
226        }
227        Self(Arc::from(v.into_boxed_slice()))
228    }
229
230    pub fn word_len(&self) -> usize {
231        self.0.len()
232    }
233
234    #[inline]
235    pub fn allows(&self, idx: NativeIdx) -> bool {
236        let (w, b) = (idx as usize / 64, idx as usize % 64);
237        match self.0.get(w) {
238            Some(word) => word & (1u64 << b) != 0,
239            None => false,
240        }
241    }
242
243    /// Attenuation primitive: result ⊆ self AND ⊆ requested, always.
244    /// Bitwise AND — there is no OR path on this type.
245    pub fn intersect(&self, requested: &NativeMask) -> NativeMask {
246        let n = self.0.len().min(requested.0.len());
247        let mut words = vec![0u64; n];
248        for i in 0..n {
249            let a = match self.0.get(i) {
250                Some(w) => *w,
251                None => 0,
252            };
253            let b = match requested.0.get(i) {
254                Some(w) => *w,
255                None => 0,
256            };
257            words[i] = a & b;
258        }
259        NativeMask(Arc::from(words.into_boxed_slice()))
260    }
261}
262
263/// What a capability addresses. Bytecode never sees this enum — only the
264/// opaque [`CapId`] token.
265#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
266pub enum CapTarget {
267    /// Delivery / link / monitor / self-authority for one flow (`u64` = FlowId).
268    Flow(u64),
269    /// Native-table authority (S7). Rarely minted; usually the mask lives on
270    /// a flow-targeted Cap that also has [`CapRights::NATIVE`].
271    NativeTable,
272    /// Exclusive target of [`CapRights::ADMIN`]: kill / inspect / quota top-up.
273    Scheduler,
274}
275
276impl CapTarget {
277    pub fn flow_id(self) -> Option<u64> {
278        match self {
279            CapTarget::Flow(id) => Some(id),
280            CapTarget::NativeTable | CapTarget::Scheduler => None,
281        }
282    }
283}
284
285/// Authorization record stored in the runtime Cap table, never in bytecode.
286#[derive(Clone, Debug, PartialEq, Eq)]
287pub struct Cap {
288    pub target: CapTarget,
289    pub rights: CapRights,
290    /// Meaningful only when `rights.contains(NATIVE)`.
291    pub native_mask: Option<NativeMask>,
292    /// Incremented by [`RevocationCell::revoke`]. A Cap is valid iff
293    /// `self.epoch == issuer.epoch()`.
294    epoch: u64,
295}
296
297/// One revocation counter per capability issuer (a flow, the native table,
298/// the scheduler). Revoke is O(1); every derived Cap dies with it.
299#[derive(Debug)]
300pub struct RevocationCell(AtomicU64);
301
302impl RevocationCell {
303    pub fn new() -> Self {
304        Self(AtomicU64::new(0))
305    }
306
307    pub fn epoch(&self) -> u64 {
308        self.0.load(Ordering::Acquire)
309    }
310
311    pub fn revoke(&self) {
312        self.0.fetch_add(1, Ordering::AcqRel);
313    }
314}
315
316impl Default for RevocationCell {
317    fn default() -> Self {
318        Self::new()
319    }
320}
321
322impl Cap {
323    /// Trusted root mint (runtime / host only). Bytecode cannot call this.
324    pub fn root(
325        target: CapTarget,
326        rights: CapRights,
327        native_mask: Option<NativeMask>,
328        cell: &RevocationCell,
329    ) -> Self {
330        Self {
331            target,
332            rights,
333            native_mask,
334            epoch: cell.epoch(),
335        }
336    }
337
338    pub fn epoch(&self) -> u64 {
339        self.epoch
340    }
341
342    pub fn is_valid(&self, cell: &RevocationCell) -> bool {
343        self.epoch == cell.epoch()
344    }
345
346    /// Produce a strictly narrower-or-equal Cap. Never panics, never grants
347    /// a bit the parent did not have. This is the single choke point the
348    /// rest of the model sits on — keep it boring and obvious.
349    pub fn attenuate(&self, want_rights: CapRights, want_native: Option<&NativeMask>) -> Cap {
350        let rights = self.rights.intersect(want_rights);
351        let native_mask = match (&self.native_mask, want_native) {
352            (Some(cur), Some(req)) => Some(cur.intersect(req)),
353            (Some(cur), None) => Some(cur.clone()),
354            (None, _) => None,
355        };
356        Cap {
357            target: self.target,
358            rights,
359            native_mask,
360            epoch: self.epoch,
361        }
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn none_is_zero_and_random_is_not() -> Result<(), CapIdError> {
371        assert!(CapId::NONE.is_none());
372        let a = CapId::random()?;
373        let b = CapId::random()?;
374        assert!(!a.is_none());
375        assert!(!b.is_none());
376        assert_ne!(a, b);
377        Ok(())
378    }
379
380    #[test]
381    fn from_raw_is_crate_internal() {
382        assert_eq!(CapId::from_raw(0), CapId::NONE);
383        assert!(!CapId::from_raw(1).is_none());
384    }
385
386    #[test]
387    fn send_ask_bits_stay_compatible() {
388        assert_eq!(CapRights::SEND.bits(), 0b01);
389        assert_eq!(CapRights::ASK.bits(), 0b10);
390        assert_eq!(CapRights::SEND_ASK.bits(), 0b11);
391        assert!(CapRights::ADMIN.bits() > 0b11);
392    }
393
394    #[test]
395    fn attenuation_never_escalates() {
396        let cell = RevocationCell::new();
397        let nm = NativeMask::from_indices(128, &[3, 5, 9]);
398        let parent = Cap::root(
399            CapTarget::Flow(1),
400            CapRights::SEND.union(CapRights::NATIVE),
401            Some(nm),
402            &cell,
403        );
404
405        let child = parent.attenuate(CapRights::SEND.union(CapRights::ADMIN), None);
406        assert!(!child.rights.contains(CapRights::ADMIN));
407        assert!(child.rights.contains(CapRights::SEND));
408
409        let wide = NativeMask::full(128);
410        let child2 = parent.attenuate(CapRights::NATIVE, Some(&wide));
411        let child_mask = child2.native_mask.as_ref();
412        assert!(child_mask.is_some_and(|m| m.allows(3)));
413        let parent_mask = parent.native_mask.as_ref();
414        assert!(parent_mask.is_some_and(|m| !m.allows(7)));
415    }
416
417    #[test]
418    fn attenuation_is_subset_for_all_byte_masks() {
419        let cell = RevocationCell::new();
420        for src in 0u32..=255 {
421            for want in 0u32..=255 {
422                let parent = Cap::root(
423                    CapTarget::Flow(1),
424                    CapRights::from_bits(src),
425                    None,
426                    &cell,
427                );
428                let child = parent.attenuate(CapRights::from_bits(want), None);
429                assert!(
430                    child.rights.is_subset_of(parent.rights),
431                    "escalation src={src:#x} want={want:#x} got={:#x}",
432                    child.rights.bits()
433                );
434                assert_eq!(child.rights.bits(), src & want);
435            }
436        }
437    }
438
439    #[test]
440    fn chained_delegate_never_gains_bits() {
441        let cell = RevocationCell::new();
442        let mut cap = Cap::root(
443            CapTarget::Flow(1),
444            CapRights::FLOW,
445            Some(NativeMask::from_indices(32, &[0, 1, 2])),
446            &cell,
447        );
448        let adversarial = [
449            CapRights::ADMIN,
450            CapRights::NATIVE.union(CapRights::ADMIN),
451            CapRights::from_bits(u32::MAX),
452            CapRights::NONE,
453            CapRights::FLOW,
454        ];
455        for want in adversarial {
456            cap = cap.attenuate(want, Some(&NativeMask::full(32)));
457            assert!(cap.rights.is_subset_of(CapRights::FLOW));
458            assert!(!cap.rights.contains(CapRights::ADMIN));
459            if let Some(mask) = &cap.native_mask {
460                assert!(!mask.allows(7));
461            }
462        }
463    }
464
465    #[test]
466    fn revocation_kills_all_derived_caps() {
467        let cell = RevocationCell::new();
468        let root = Cap::root(CapTarget::Flow(1), CapRights::SEND, None, &cell);
469        let child = root.attenuate(CapRights::SEND, None);
470        assert!(child.is_valid(&cell));
471        cell.revoke();
472        assert!(!child.is_valid(&cell));
473        assert!(!root.is_valid(&cell));
474    }
475}