Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci_main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/ci_pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

[workspace]
resolver = "3"
members = ["kernel", "xtask"]
members = ["kernel", "xtask", "capability"]

[workspace.package]
edition = "2024"
Expand Down
21 changes: 21 additions & 0 deletions capability/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions capability/src/error.rs
Original file line number Diff line number Diff line change
@@ -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,
}
80 changes: 80 additions & 0 deletions capability/src/generation.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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)));
}
}
69 changes: 69 additions & 0 deletions capability/src/handle.rs
Original file line number Diff line number Diff line change
@@ -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));
}
}
41 changes: 41 additions & 0 deletions capability/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Loading