diff --git a/.github/workflows/ci_main.yml b/.github/workflows/ci_main.yml index 3b65129..1f3f70c 100644 --- a/.github/workflows/ci_main.yml +++ b/.github/workflows/ci_main.yml @@ -79,6 +79,11 @@ jobs: cargo clippy --package setonix-kernel --all-features --target "$target" -- -D warnings cargo build --verbose --package setonix-kernel --target "$target" cargo build --release --verbose --package setonix-kernel --target "$target" + # The capability crate is architecture-independent no_std + # logic; building it for both Tier-1 targets proves it + # stays no_std-clean. + cargo clippy --package setonix-capability --target "$target" -- -D warnings + cargo build --package setonix-capability --target "$target" echo "::endgroup::" done @@ -87,6 +92,14 @@ jobs: cargo test --verbose --package xtask echo "::endgroup::" + # Host-testable kernel logic. The capability table's pure logic + # lives in its own crate precisely so it can run a test harness + # (a bare-metal target cannot). + echo "::group::capability (host)" + cargo clippy --package setonix-capability --all-targets --all-features -- -D warnings + cargo test --verbose --package setonix-capability + echo "::endgroup::" + boot: name: Boot (QEMU aarch64 virt) needs: checks diff --git a/.github/workflows/ci_pr.yml b/.github/workflows/ci_pr.yml index e1e2313..b3cf340 100644 --- a/.github/workflows/ci_pr.yml +++ b/.github/workflows/ci_pr.yml @@ -81,6 +81,11 @@ jobs: cargo clippy --package setonix-kernel --all-features --target "$target" -- -D warnings cargo build --verbose --package setonix-kernel --target "$target" cargo build --release --verbose --package setonix-kernel --target "$target" + # The capability crate is architecture-independent no_std + # logic; building it for both Tier-1 targets proves it + # stays no_std-clean. + cargo clippy --package setonix-capability --target "$target" -- -D warnings + cargo build --package setonix-capability --target "$target" echo "::endgroup::" done @@ -89,6 +94,14 @@ jobs: cargo test --verbose --package xtask echo "::endgroup::" + # Host-testable kernel logic. The capability table's pure logic + # lives in its own crate precisely so it can run a test harness + # (a bare-metal target cannot). + echo "::group::capability (host)" + cargo clippy --package setonix-capability --all-targets --all-features -- -D warnings + cargo test --verbose --package setonix-capability + echo "::endgroup::" + boot: name: Boot (QEMU aarch64 virt) needs: checks diff --git a/CHANGELOG.md b/CHANGELOG.md index a505a38..6908782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ Release codenames follow the six Noongar seasons — Birak, Bunuru, Djeran, Maku ### Added +- **The capability table begins — RFC-0003 turns into code, first increment.** A new `no_std` + workspace crate, `setonix-capability`, holding the pure, architecture-independent logic of the + capability spine so it can be **host-unit-tested** — a bare-metal target has no test harness, so the + security-critical logic lives where it can be exercised exhaustively. This increment is the value + types the table is built from, each with a compiler-checked invariant: + - `Rights` — a closed bitmask (`DUPLICATE`, `TRANSFER`, `READ`, `WRITE`, `REVOKE`) whose only + attenuation operation, `diminish`, is subset-only. Non-widenability (O-2) is a property of the + type: there is no constructor from arbitrary bits and no operation that adds a right. A test + checks *exhaustively* over all 32 rights combinations that a successful `diminish` is always a + subset of its source. + - `Generation` — a 64-bit counter, **fail-closed on exhaustion** (`next` returns `None` rather than + wrapping to a value a stale handle could match), the defence behind O-1's reuse case and O-3's + destruction case. + - `Handle` — the userspace-facing name: a table index plus the generation its slot held when + minted; carries no authority by itself. + - `CapabilityError` — a fail-closed error for every table operation. + Twelve host tests, clippy-clean under `-D warnings`, and built for both Tier-1 targets to prove it + stays `no_std`. CI gains a capability host-test group and per-target build. The owned no-`Clone` + `Capability` value and the flat table follow in the next increment (§5.3: small, reviewable steps). - **Exception vectors, and a reporter that says what went wrong in one line** — the instrument the soft-float bug had to be diagnosed without. `boot.s` installs a 2 KiB-aligned sixteen-entry vector table into `VBAR_EL1` before the first Rust instruction runs, so even the earliest fault is reported diff --git a/CLAUDE.md b/CLAUDE.md index 8cd830e..7e3c6e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,9 +38,16 @@ kernel/ the kernel crate (no_std, no_main) src/main.rs architecture-independent entry; contains no unsafe, ever src/arch/ the HAL boundary — the only tree that knows the architecture link/ per-architecture linker scripts +capability/ the capability table (RFC-0003): pure no_std logic, host-tested xtask/ build, run and boot-test automation (host binary, no deps) ``` +Architecture-independent kernel logic that benefits from host unit tests lives in +its own `no_std` workspace crate (like `capability/`), because the kernel crate +cannot run a test harness on a bare-metal target. Such a crate is `#![no_std]` in +the kernel build and `std` under `cargo test`; it builds for both Tier-1 targets +in CI to prove it stays `no_std`. + ## Architectures Tier 1, both first-class from day one: `aarch64-unknown-none-softfloat` and diff --git a/Cargo.lock b/Cargo.lock index c7b170b..007f410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,10 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "setonix-capability" +version = "0.0.0" + [[package]] name = "setonix-kernel" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index c94b224..5053f40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ [workspace] resolver = "3" -members = ["kernel", "xtask"] +members = ["kernel", "xtask", "capability"] [workspace.package] edition = "2024" diff --git a/capability/Cargo.toml b/capability/Cargo.toml new file mode 100644 index 0000000..8c8bbec --- /dev/null +++ b/capability/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "setonix-capability" +version = "0.0.0" +description = "The Setonix capability table — unforgeable, rights-attenuating object references (RFC-0003)" +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish = false + +# No dependencies, deliberately. A security-critical primitive is read far more +# often than it is written, and every line of it must be explainable (§5.2). +# `#![no_std]` in the kernel build, `std` under `cargo test` so the pure logic +# is host-unit-tested — the kernel crate cannot run a test harness on bare metal. + +[lib] +name = "setonix_capability" +path = "src/lib.rs" + +[lints] +workspace = true diff --git a/capability/src/error.rs b/capability/src/error.rs new file mode 100644 index 0000000..5433417 --- /dev/null +++ b/capability/src/error.rs @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Errors from capability-table operations. + +/// Why a capability-table operation failed. +/// +/// Every variant fails closed: the operation grants no authority and changes no +/// state a caller could mistake for success. There is no "partial" outcome. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CapabilityError { + /// The handle's index lies outside the table. + OutOfBounds, + /// The slot the handle names is empty. + Empty, + /// The slot's generation no longer matches the handle's: the capability it + /// named has been closed, or the slot reused — a stale handle (RFC-0003 O-1). + StaleGeneration, + /// The requested rights are not a subset of the source capability's: a + /// widening was attempted, which the type refuses (RFC-0003 O-2). + RightsNotSubset, + /// The source capability lacks `DUPLICATE`, so nothing may be derived from it. + NotDuplicable, + /// The table has no free slot to mint into. + TableFull, + /// A slot's generation counter is exhausted; the slot is retired rather than + /// wrapped (the fail-closed boundary — see [`Generation`](crate::Generation)). + GenerationExhausted, +} diff --git a/capability/src/generation.rs b/capability/src/generation.rs new file mode 100644 index 0000000..735472f --- /dev/null +++ b/capability/src/generation.rs @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Generation counters — the defence against handle reuse (the ABA problem). + +/// A counter that distinguishes successive occupants of one table slot, and +/// successive incarnations of one object. +/// +/// A [`Handle`](crate::Handle) records the generation its slot held when the +/// handle was minted; resolving the handle checks that generation still matches. +/// Closing a capability and reusing its slot, or destroying an object, bumps the +/// generation — so a stale handle fails closed rather than resolving to whatever +/// now occupies the slot. This is the mechanism behind RFC-0003 O-1 +/// (unforgeability against reuse) and the destruction half of O-3 (revocation by +/// making outstanding capabilities inert). +/// +/// 64 bits wide and **fail-closed on exhaustion** (RFC-0003 amendment): a slot +/// that somehow exhausts its generations is retired, never wrapped back to a +/// value a live handle might match. At 2^64 reuses of a single slot, exhaustion +/// is unreachable in practice; the type refuses to wrap regardless, so the +/// guarantee does not rest on that improbability. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)] +pub struct Generation(u64); + +impl Generation { + /// The generation of a freshly created slot or object. Starts at 1, leaving + /// 0 available as a "never allocated" sentinel for later use. + pub const FIRST: Self = Self(1); + + /// The next generation, or [`None`] if this one is the last representable — + /// the fail-closed boundary. A caller that receives [`None`] must retire the + /// slot, never reuse it. + #[must_use] + pub const fn next(self) -> Option { + match self.0.checked_add(1) { + Some(n) => Some(Self(n)), + None => None, + } + } + + /// The raw value, for packing into a handle or comparing on resolve. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +#[cfg(test)] +#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::Generation; + + #[test] + fn first_is_one_and_advances() { + assert_eq!(Generation::FIRST.get(), 1); + assert_eq!(Generation::FIRST.next(), Some(Generation(2))); + assert_eq!(Generation(2).next(), Some(Generation(3))); + } + + #[test] + fn generations_are_strictly_increasing_and_distinct() { + let mut g = Generation::FIRST; + let mut previous = None; + for _ in 0..1000 { + if let Some(p) = previous { + assert!(g > p); + } + previous = Some(g); + g = g.next().expect("u64 does not exhaust in 1000 steps"); + } + } + + #[test] + fn exhaustion_fails_closed_rather_than_wrapping() { + // The whole point: at the boundary, `next` returns None instead of + // wrapping to a value a stale handle could match. + assert_eq!(Generation(u64::MAX).next(), None); + // And one before the boundary still advances. + assert_eq!(Generation(u64::MAX - 1).next(), Some(Generation(u64::MAX))); + } +} diff --git a/capability/src/handle.rs b/capability/src/handle.rs new file mode 100644 index 0000000..7c4bec2 --- /dev/null +++ b/capability/src/handle.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Handles — the userspace-facing name for a capability. + +use crate::Generation; + +/// The value a process holds to name one of its own capabilities: an index into +/// its capability table, plus the [`Generation`] the slot held when the handle +/// was minted. +/// +/// A handle carries no authority by itself and is meaningless outside the +/// process that holds it — it is a lookup key the kernel resolves against a table +/// only the kernel can write (RFC-0003 §3). Two things make it safe to hand to +/// userspace: the capability it names lives in kernel memory, so there is +/// nothing to forge; and the generation makes a stale handle fail closed instead +/// of aliasing a reused slot. Handle `7` in one process and handle `7` in another +/// name unrelated capabilities, or none. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub struct Handle { + index: u32, + generation: Generation, +} + +impl Handle { + /// Constructs a handle naming slot `index` at `generation`. Minted by the + /// table when it stores a capability; a value userspace holds but cannot + /// turn into authority it was not granted, because every resolve re-checks + /// the generation against the live slot. + #[must_use] + pub const fn new(index: u32, generation: Generation) -> Self { + Self { index, generation } + } + + /// The table slot this handle names. + #[must_use] + pub const fn index(self) -> u32 { + self.index + } + + /// The generation this handle was minted at. + #[must_use] + pub const fn generation(self) -> Generation { + self.generation + } +} + +#[cfg(test)] +#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::Handle; + use crate::Generation; + + #[test] + fn round_trips_index_and_generation() { + let h = Handle::new(7, Generation::FIRST); + assert_eq!(h.index(), 7); + assert_eq!(h.generation(), Generation::FIRST); + } + + #[test] + fn handles_differing_in_generation_are_distinct() { + let g2 = Generation::FIRST.next().expect("second generation exists"); + assert_ne!(Handle::new(7, Generation::FIRST), Handle::new(7, g2)); + // Same slot, same generation: equal. + assert_eq!(Handle::new(7, g2), Handle::new(7, g2)); + // Same generation, different slot: distinct. + assert_ne!(Handle::new(7, g2), Handle::new(8, g2)); + } +} diff --git a/capability/src/lib.rs b/capability/src/lib.rs new file mode 100644 index 0000000..83024b6 --- /dev/null +++ b/capability/src/lib.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The Setonix capability table — RFC-0003. +//! +//! Unforgeable, rights-attenuating references to kernel objects. This crate +//! holds the *mechanism*: architecture-independent, `no_std`, allocation-free +//! logic, unit-tested on the host. It lives outside the kernel crate precisely +//! so it *can* be host-tested — a bare-metal target has no test harness — and +//! so the security spine can be exercised exhaustively away from the hardware. +//! +//! This first increment is the value types the table is built from. The owned, +//! no-`Clone` capability and the flat per-process table follow, one reviewable +//! step at a time (§5.3). +//! +//! What the design guarantees, and where it lives: +//! +//! - **Unforgeability (O-1).** Userspace holds a [`Handle`] — a table index plus +//! a [`Generation`] — never the capability itself, which is kernel memory. A +//! stale handle fails closed against the generation rather than aliasing a +//! reused slot. +//! - **Non-widenability (O-2).** [`Rights`] only ever *diminish*: no operation +//! anywhere adds a right, and [`Rights::diminish`] is the sole way to change a +//! held capability's rights. +//! +//! Per the RFC-0003 prior-art amendment, the compile-time guarantees here cover +//! the kernel's *internal* handling; the userspace-observable cross-process +//! transfer is a runtime table operation the generation scheme secures — the +//! borrow checker cannot span protection domains, so this crate does not pretend +//! it does. + +#![cfg_attr(not(test), no_std)] + +mod error; +mod generation; +mod handle; +mod rights; + +pub use error::CapabilityError; +pub use generation::Generation; +pub use handle::Handle; +pub use rights::Rights; diff --git a/capability/src/rights.rs b/capability/src/rights.rs new file mode 100644 index 0000000..4963a0d --- /dev/null +++ b/capability/src/rights.rs @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Capability rights — the attenuable authority a capability carries. + +use core::fmt; + +/// The set of operations a capability permits on its object. +/// +/// A small, closed bitmask. Rights only ever *diminish* along a derivation +/// chain: [`Rights::diminish`] can drop bits, and no operation anywhere adds +/// one. That is RFC-0003 O-2 (non-widenability) made a property of the type +/// rather than a rule a reviewer must remember. +/// +/// There is deliberately no constructor from arbitrary bits. A `Rights` is only +/// ever assembled from the named constants (with [`Rights::union`] at mint time) +/// and narrowed with [`Rights::diminish`] — never conjured from an integer a +/// caller supplies, which is what keeps an undefined bit from ever meaning +/// authority. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Rights(u32); + +impl Rights { + /// No rights: a capability that permits nothing, though it still names its + /// object. + pub const NONE: Self = Self(0); + /// May derive further capabilities from this one (see [`diminish`](Self::diminish)). + pub const DUPLICATE: Self = Self(1 << 0); + /// May transfer this capability to another process (in an IPC message). + pub const TRANSFER: Self = Self(1 << 1); + /// May read the object's state, or receive from it. + pub const READ: Self = Self(1 << 2); + /// May modify the object's state, or send to it. + pub const WRITE: Self = Self(1 << 3); + /// May revoke capabilities derived from this one. + pub const REVOKE: Self = Self(1 << 4); + + /// Every defined right — the union of all named bits. Computed from the + /// constants rather than written as a literal, so adding a right above + /// extends this automatically and no undefined bit is ever included. + pub const ALL: Self = + Self(Self::DUPLICATE.0 | Self::TRANSFER.0 | Self::READ.0 | Self::WRITE.0 | Self::REVOKE.0); + + /// Whether `self` includes every right in `other`. + #[must_use] + pub const fn contains(self, other: Self) -> bool { + (self.0 & other.0) == other.0 + } + + /// Whether every right in `self` is also in `other`. + #[must_use] + pub const fn is_subset_of(self, other: Self) -> bool { + other.contains(self) + } + + /// The rights present in both `self` and `other`. + #[must_use] + pub const fn intersection(self, other: Self) -> Self { + Self(self.0 & other.0) + } + + /// Combine two rights sets — for *assembling* an initial set at mint time, + /// e.g. `Rights::READ.union(Rights::WRITE)`. + /// + /// This is a constructor convenience, not an operation on a held capability: + /// the only way a *held* capability's rights change is [`diminish`](Self::diminish), + /// which cannot widen. Union is never applied to narrow an existing + /// capability, so O-2 is not at risk from its existence. + #[must_use] + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + /// Attenuate to `requested` — the O-2 operation. Succeeds only if `requested` + /// is a subset of `self`, so a derivation can never gain a right its parent + /// lacked. Returns [`None`] if `requested` asks for a right `self` does not + /// hold. + #[must_use] + pub const fn diminish(self, requested: Self) -> Option { + if self.contains(requested) { + Some(requested) + } else { + None + } + } + + /// The raw bits, for packing into a stored capability or an IPC message. + #[must_use] + pub const fn bits(self) -> u32 { + self.0 + } +} + +impl fmt::Debug for Rights { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Rights(")?; + let mut first = true; + for (name, bit) in [ + ("DUPLICATE", Self::DUPLICATE), + ("TRANSFER", Self::TRANSFER), + ("READ", Self::READ), + ("WRITE", Self::WRITE), + ("REVOKE", Self::REVOKE), + ] { + if self.contains(bit) { + if !first { + write!(f, "|")?; + } + write!(f, "{name}")?; + first = false; + } + } + if first { + write!(f, "NONE")?; + } + write!(f, ")") + } +} + +#[cfg(test)] +#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::Rights; + + #[test] + fn all_contains_every_named_right() { + for bit in [ + Rights::DUPLICATE, + Rights::TRANSFER, + Rights::READ, + Rights::WRITE, + Rights::REVOKE, + ] { + assert!(Rights::ALL.contains(bit)); + } + } + + #[test] + fn none_contains_only_none() { + assert!(Rights::ALL.contains(Rights::NONE)); + assert!(Rights::NONE.contains(Rights::NONE)); + assert!(!Rights::NONE.contains(Rights::READ)); + } + + #[test] + fn subset_is_the_inverse_of_contains() { + let rw = Rights::READ.union(Rights::WRITE); + assert!(rw.is_subset_of(Rights::ALL)); + assert!(Rights::READ.is_subset_of(rw)); + assert!(!rw.is_subset_of(Rights::READ)); + } + + #[test] + fn diminish_narrows_but_never_widens() { + let rw = Rights::READ.union(Rights::WRITE); + // Narrowing to a held subset succeeds and yields exactly that subset. + assert_eq!(rw.diminish(Rights::READ), Some(Rights::READ)); + assert_eq!(rw.diminish(rw), Some(rw)); + assert_eq!(rw.diminish(Rights::NONE), Some(Rights::NONE)); + // Asking for a right not held fails closed — the O-2 guarantee. + assert_eq!(rw.diminish(Rights::TRANSFER), None); + assert_eq!(Rights::READ.diminish(Rights::WRITE), None); + assert_eq!(Rights::NONE.diminish(Rights::READ), None); + } + + #[test] + fn diminish_result_is_always_a_subset_of_the_source() { + // Exhaustively, for every source/request pair over the 5 defined bits, + // a successful diminish returns a subset of the source. This is the + // property O-2 rests on, checked rather than asserted. + for s in 0u32..32 { + for r in 0u32..32 { + let source = rights_from_low_bits(s); + let requested = rights_from_low_bits(r); + if let Some(result) = source.diminish(requested) { + assert!(result.is_subset_of(source)); + } + } + } + } + + #[test] + fn intersection_and_union_are_dual() { + let rw = Rights::READ.union(Rights::WRITE); + let wt = Rights::WRITE.union(Rights::TRANSFER); + assert_eq!(rw.intersection(wt), Rights::WRITE); + assert!(rw.union(wt).contains(Rights::READ)); + assert!(rw.union(wt).contains(Rights::TRANSFER)); + } + + #[test] + fn debug_lists_set_bits() { + assert_eq!(format!("{:?}", Rights::NONE), "Rights(NONE)"); + assert_eq!(format!("{:?}", Rights::READ), "Rights(READ)"); + assert_eq!( + format!("{:?}", Rights::READ.union(Rights::WRITE)), + "Rights(READ|WRITE)" + ); + } + + /// Build a `Rights` from the low 5 bits of `n`, for exhaustive testing only. + fn rights_from_low_bits(n: u32) -> Rights { + let mut r = Rights::NONE; + for (i, bit) in [ + Rights::DUPLICATE, + Rights::TRANSFER, + Rights::READ, + Rights::WRITE, + Rights::REVOKE, + ] + .into_iter() + .enumerate() + { + if n & (1 << i) != 0 { + r = r.union(bit); + } + } + r + } +}