diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..66322da --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,88 @@ +# Security Model + +capsec enforces I/O permissions at compile time via a zero-cost capability type system. This document describes what it protects against, what it doesn't, and where the boundaries are. + +## The security boundary: `Cap::new()` is `pub(crate)` + +The only way to construct a `Cap

` in safe Rust is through `CapRoot::grant()`. The constructor `Cap::new()` is `pub(crate)` — external crates cannot call it. + +```rust +// This is the entire security gate: +pub(crate) fn new() -> Self { ... } +``` + +Every capability in a capsec program traces back to a `CapRoot::grant()` call. `CapRoot` is a singleton — `root()` panics if called twice, ensuring a single point of authority. + +## `Has

` is intentionally open + +The `Has

` trait is open for external implementation. This is by design — the `#[capsec::context]` macro generates `impl Has

` on user-defined structs to enable capability threading through call stacks. + +This is safe because `Has

` requires returning a `Cap

`: + +```rust +pub trait Has { + fn cap_ref(&self) -> Cap

; +} +``` + +Any implementation must produce a `Cap

`. Since `Cap::new()` is `pub(crate)`, the only way to satisfy this in safe Rust is to hold a real `Cap

` obtained from `CapRoot::grant()`. + +## Enforcement: type-witnessed proofs + +All `capsec-std` and `capsec-tokio` wrapper functions extract a typed proof before executing I/O: + +```rust +pub fn read(path: impl AsRef, cap: &impl Has) -> Result, CapSecError> { + let _proof: Cap = cap.cap_ref(); // proof extracted here + Ok(std::fs::read(path)?) // I/O happens here +} +``` + +The concrete type annotation `Cap` is not cosmetic. It forces the compiler to resolve the return type of `cap_ref()` and prevents dead-code elimination. If a `Has

` implementation diverges (panics, loops, calls `process::exit`), the divergence fires at the `_proof` line — the I/O on the next line never executes. + +The adversarial test suite (`capsec-tests/tests/type_system.rs`, section L) proves this with a `PanicForge` impl that panics in `cap_ref()` — the test confirms the panic fires before `std::fs::read` runs. + +## What capsec does NOT protect against + +### `unsafe` code + +`transmute`, `MaybeUninit`, and `ptr::read` can forge a `Cap

` because `Cap` is a zero-sized type. All three attacks are documented and tested in `capsec-tests/tests/type_system.rs` (section C). This is expected — capsec's threat model is safe Rust only. + +### Direct `std` / `tokio` calls + +A function can always call `std::fs::read()` or `tokio::fs::read()` directly without a capability token. The Rust compiler won't stop it — capsec wrappers are opt-in replacements, not mandatory. + +This is where `cargo capsec audit` comes in. The audit tool statically scans source code for ambient authority calls and reports them. Functions annotated with `#[capsec::deny(all)]` that contain I/O calls are promoted to critical risk. + +### FFI and inline assembly + +`extern` blocks and inline `asm!` interact with the OS directly. The audit tool flags `extern` blocks but cannot analyze what foreign code does. + +### Proc-macro-generated code + +Code generated by procedural macros is invisible to the static scanner. This is inherent to syntax-level tooling. + +## Audit tool scope + +`cargo capsec audit` is an AST-level heuristic scanner, not a data flow analyzer. It: + +- Detects qualified calls (`std::fs::read`, `TcpStream::connect`, `Command::new`) +- Expands import aliases (`use std::fs::read as load; load(...)`) +- Handles glob imports (`use std::fs::*; read(...)`) +- Uses contextual method matching (`.output()` only flags when `Command::new` is in the same function) +- Flags `extern` blocks as FFI findings + +Known blind spots (documented in `capsec-tests/tests/audit_evasion.rs`): + +- Function pointers and closures that hide the call target +- `include!()` directives that pull in code from other files +- Module re-exports across crate boundaries +- Dependency re-exports (a crate wrapping `std::fs` under a different name) +- `libc` / `nix` crate calls (not in the pattern registry) +- `cfg`-conditional code that may or may not compile + +## Reporting vulnerabilities + +If you discover a security issue in capsec, please email the maintainers directly rather than opening a public issue. Contact: [open an issue with the `security` label](https://github.com/bordumb/capsec/issues/new?labels=security). + +For issues that are not security-sensitive (e.g., audit tool false negatives, documentation gaps), please open a regular GitHub issue. diff --git a/crates/capsec-tests/tests/type_system.rs b/crates/capsec-tests/tests/type_system.rs index 4b49950..e9ac0ef 100644 --- a/crates/capsec-tests/tests/type_system.rs +++ b/crates/capsec-tests/tests/type_system.rs @@ -432,3 +432,50 @@ fn create_returns_writable_file() { std::io::Write::write_all(&mut file, b"test").unwrap(); std::fs::remove_file(&path).ok(); } + +// ============================================================================ +// L. FORGERY DEFENSE — proving the _proof pattern stops diverging Has

impls +// ============================================================================ +// +// The _proof pattern (`let _proof: Cap

= cap.cap_ref()`) forces cap_ref() +// to evaluate and return before any I/O executes. If an external impl of +// Has

diverges (panic, loop, process::exit), the divergence fires at the +// _proof line — the I/O on the next line never runs. +// +// This is the enforcement mechanism that makes open Has

safe: you can +// implement the trait, but you can't skip the proof extraction. + +/// A malicious Has impl that panics instead of returning a Cap. +struct PanicForge; + +impl Has for PanicForge { + fn cap_ref(&self) -> Cap { + panic!("forged capability — this should fire before I/O"); + } +} + +/// Proves: a diverging Has

impl (panic) fires BEFORE std::fs::read executes. +/// If the _proof pattern were missing or incorrect, the I/O would run first. +#[test] +#[should_panic(expected = "forged capability")] +fn forged_has_impl_panics_before_io_executes() { + let _ = capsec_std::fs::read("/dev/null", &PanicForge); +} + +/// A malicious Has impl that loops forever instead of returning a Cap. +/// +/// Cannot be tested directly (would hang the test runner), but the behavior is +/// correct: LoopForge::cap_ref() never returns, so the I/O call on the next +/// line of the wrapper function is never reached. +/// +/// This struct exists to document the behavior and prove it compiles — +/// confirming that Has

being open does not enable I/O bypass via divergence. +#[allow(dead_code)] +struct LoopForge; + +#[allow(dead_code)] +impl Has for LoopForge { + fn cap_ref(&self) -> Cap { + loop {} // diverges — I/O never executes + } +}