Skip to main content

cellos_core/
host_baseline.rs

1//! Host baseline self-attestation (S52, ADR-0033).
2//!
3//! Probes the host's isolation primitives (user namespaces, cgroup v2, seccomp,
4//! nftables/netfilter, KVM) and records a structured, signable
5//! [`HostBaselineAttestationV1`]. On Linux the probe reads `/proc` + `/sys` +
6//! `/dev`; off Linux every control is `unsupported` (declared-audit, OUT of
7//! accreditation scope — ADR-0033). The attestation is a **producer claim**
8//! folded into a signed startup receipt so an auditor sees the host posture
9//! under the org-root key — not a measured/hardware attestation (that is the
10//! deferred hardware-root-of-trust track).
11
12use crate::crypto::TrustAnchorPublicKey;
13use crate::error::CellosError;
14use crate::trust_keys::Signer;
15use crate::types::CloudEventV1;
16use crate::{sign_event_with, verify_signed_event_envelope, SignedEventEnvelopeV1};
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19
20/// CloudEvent `type` of a signed host-baseline attestation (S52).
21pub const HOST_BASELINE_ATTESTATION_TYPE: &str =
22    "dev.cellos.events.cell.host.v1.baseline-attestation";
23
24/// The baseline isolation controls cellos attests, in a stable order.
25pub const BASELINE_CONTROLS: &[&str] =
26    &["user-namespaces", "cgroup-v2", "seccomp", "nftables", "kvm"];
27
28/// One probed baseline control.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct BaselineControl {
32    /// Control name (one of [`BASELINE_CONTROLS`]).
33    pub name: String,
34    /// Whether the primitive was found present on this host.
35    pub satisfied: bool,
36    /// Human-legible probe detail (the path checked / why it failed).
37    pub detail: String,
38}
39
40/// Structured host-baseline attestation (S52).
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct HostBaselineAttestationV1 {
44    /// Document format version.
45    pub schema_version: String,
46    /// `linux-probed` (real `/proc`+`/sys`+`/dev` checks) or
47    /// `declared-unsupported` (off-Linux — declared-audit, out of scope).
48    pub probe_mode: String,
49    /// Per-control results, in [`BASELINE_CONTROLS`] order.
50    pub controls: Vec<BaselineControl>,
51    /// True iff every control is satisfied.
52    pub all_satisfied: bool,
53}
54
55#[cfg(target_os = "linux")]
56fn probe_mode() -> &'static str {
57    "linux-probed"
58}
59#[cfg(not(target_os = "linux"))]
60fn probe_mode() -> &'static str {
61    "declared-unsupported"
62}
63
64#[cfg(target_os = "linux")]
65fn probe_one(name: &str) -> (bool, String) {
66    use std::path::Path;
67    let exists = |p: &str| {
68        if Path::new(p).exists() {
69            (true, format!("{p} present"))
70        } else {
71            (false, format!("{p} absent"))
72        }
73    };
74    match name {
75        "user-namespaces" => exists("/proc/self/ns/user"),
76        "cgroup-v2" => exists("/sys/fs/cgroup/cgroup.controllers"),
77        "seccomp" => match std::fs::read_to_string("/proc/self/status") {
78            Ok(s) if s.contains("Seccomp:") => (
79                true,
80                "/proc/self/status carries a Seccomp field".to_string(),
81            ),
82            Ok(_) => (false, "no Seccomp field in /proc/self/status".to_string()),
83            Err(e) => (false, format!("read /proc/self/status: {e}")),
84        },
85        // Coarse availability probe: the netfilter subsystem is present. Not a
86        // guarantee nft rules can be installed (that needs CAP_NET_ADMIN), but a
87        // host without it cannot enforce the egress boundary at all.
88        "nftables" => exists("/proc/sys/net/netfilter"),
89        "kvm" => exists("/dev/kvm"),
90        _ => (false, "unknown control".to_string()),
91    }
92}
93#[cfg(not(target_os = "linux"))]
94fn probe_one(_name: &str) -> (bool, String) {
95    (
96        false,
97        "not a Linux host — declared-audit, out of accreditation scope (ADR-0033)".to_string(),
98    )
99}
100
101/// Probe the host baseline. cellos-core is env-pure (doctrine D11), so the
102/// test-override for the unsatisfied path is passed explicitly via
103/// [`probe_host_baseline_with_forced`] — the supervisor reads its env knob and
104/// forwards the list. This no-arg form probes the real host.
105#[must_use]
106pub fn probe_host_baseline() -> HostBaselineAttestationV1 {
107    probe_host_baseline_with_forced(&[])
108}
109
110/// Probe the host baseline, forcing each control named in `forced_missing` to
111/// `satisfied = false` (for exercising the unsatisfied/fail-stop path without a
112/// host that actually lacks the primitive). `forced_missing` is supplied by the
113/// caller — cellos-core itself reads no process env (doctrine D11).
114#[must_use]
115pub fn probe_host_baseline_with_forced(forced_missing: &[&str]) -> HostBaselineAttestationV1 {
116    let controls: Vec<BaselineControl> = BASELINE_CONTROLS
117        .iter()
118        .map(|&name| {
119            let force = forced_missing.contains(&name);
120            let (satisfied, detail) = if force {
121                (false, "forced missing (test override)".to_string())
122            } else {
123                probe_one(name)
124            };
125            BaselineControl {
126                name: name.to_string(),
127                satisfied,
128                detail,
129            }
130        })
131        .collect();
132    let all_satisfied = controls.iter().all(|c| c.satisfied);
133
134    HostBaselineAttestationV1 {
135        schema_version: "1.0.0".to_string(),
136        probe_mode: probe_mode().to_string(),
137        controls,
138        all_satisfied,
139    }
140}
141
142/// Build the (unsigned) CloudEvent carrying a host-baseline attestation (S52).
143///
144/// Shared by [`sign_host_baseline_attestation`] and the supervisor's signed
145/// startup-receipt path (C01) so both emit a byte-identical event body — the
146/// supervisor routes this event through its standalone signer (redact-before-
147/// sign, same transport as admission receipts) rather than re-deriving the
148/// envelope here.
149///
150/// # Errors
151///
152/// Returns [`CellosError`] if the attestation fails to serialize.
153pub fn host_baseline_event(
154    attestation: &HostBaselineAttestationV1,
155) -> Result<CloudEventV1, CellosError> {
156    let data = serde_json::to_value(attestation).map_err(|e| {
157        CellosError::InvalidSpec(format!("host-baseline attestation: serialize: {e}"))
158    })?;
159    Ok(CloudEventV1 {
160        specversion: "1.0".into(),
161        id: "host-baseline".into(),
162        source: "cellos-supervisor".into(),
163        ty: HOST_BASELINE_ATTESTATION_TYPE.into(),
164        datacontenttype: Some("application/json".into()),
165        data: Some(data),
166        time: None,
167        traceparent: None,
168        cex: None,
169    })
170}
171
172/// Sign a host-baseline attestation as an offline-verifiable startup receipt
173/// (S52). Verifiable with only the org-root public key.
174///
175/// # Errors
176///
177/// Returns [`CellosError`] if signing fails.
178pub fn sign_host_baseline_attestation(
179    signer: &dyn Signer,
180    attestation: &HostBaselineAttestationV1,
181) -> Result<SignedEventEnvelopeV1, CellosError> {
182    let event = host_baseline_event(attestation)?;
183    sign_event_with(signer, &event)
184}
185
186/// Verify a signed host-baseline attestation offline and return the parsed
187/// attestation (S52).
188///
189/// # Errors
190///
191/// Returns [`CellosError`] on signature failure or a malformed payload.
192pub fn verify_host_baseline_attestation(
193    envelope: &SignedEventEnvelopeV1,
194    verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
195) -> Result<HostBaselineAttestationV1, CellosError> {
196    let hmac: HashMap<String, Vec<u8>> = HashMap::new();
197    let event = verify_signed_event_envelope(envelope, verifying_keys, &hmac)?;
198    let data = event
199        .data
200        .as_ref()
201        .ok_or_else(|| CellosError::InvalidSpec("host-baseline: missing data".into()))?;
202    serde_json::from_value(data.clone())
203        .map_err(|e| CellosError::InvalidSpec(format!("host-baseline: parse: {e}")))
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::SoftwareSigner;
210
211    #[test]
212    fn probe_enumerates_all_baseline_controls() {
213        let att = probe_host_baseline();
214        assert_eq!(att.controls.len(), BASELINE_CONTROLS.len());
215        for (c, expected) in att.controls.iter().zip(BASELINE_CONTROLS) {
216            assert_eq!(&c.name, expected, "controls are in the stable order");
217        }
218    }
219
220    #[test]
221    fn forced_missing_control_is_unsatisfied_and_blocks_all() {
222        let att = probe_host_baseline_with_forced(&["seccomp"]);
223        let seccomp = att.controls.iter().find(|c| c.name == "seccomp").unwrap();
224        assert!(!seccomp.satisfied, "forced control must be unsatisfied");
225        assert!(seccomp.detail.contains("forced"));
226        assert!(
227            !att.all_satisfied,
228            "a single missing control blocks all_satisfied"
229        );
230    }
231
232    #[test]
233    fn host_baseline_event_carries_attestation_and_type() {
234        let att = probe_host_baseline();
235        let event = host_baseline_event(&att).expect("build event");
236        assert_eq!(event.ty, HOST_BASELINE_ATTESTATION_TYPE);
237        let parsed: HostBaselineAttestationV1 =
238            serde_json::from_value(event.data.expect("data present")).expect("parse back");
239        assert_eq!(parsed, att, "event body round-trips to the attestation");
240    }
241
242    #[test]
243    fn attestation_signs_and_verifies_offline() {
244        let att = probe_host_baseline();
245        let signer = SoftwareSigner::from_seed("org-root-2026", [8u8; 32]).unwrap();
246        let envelope = sign_host_baseline_attestation(&signer, &att).unwrap();
247
248        use crate::trust_keys::Signer as _;
249        let mut keys = HashMap::new();
250        keys.insert("org-root-2026".to_string(), signer.verifying_key());
251        let verified = verify_host_baseline_attestation(&envelope, &keys)
252            .expect("host-baseline attestation verifies offline");
253        assert_eq!(verified, att, "round-trips through sign+verify");
254
255        // tamper -> verify fails
256        let mut tampered = envelope;
257        tampered.event.id = "tampered".into();
258        assert!(verify_host_baseline_attestation(&tampered, &keys).is_err());
259    }
260}