cellos-core 0.8.0-pre

CellOS domain types and ports — typed authority, formation DAG, CloudEvent envelopes, RBAC primitives. No I/O.
Documentation
//! Host baseline self-attestation (S52, ADR-0033).
//!
//! Probes the host's isolation primitives (user namespaces, cgroup v2, seccomp,
//! nftables/netfilter, KVM) and records a structured, signable
//! [`HostBaselineAttestationV1`]. On Linux the probe reads `/proc` + `/sys` +
//! `/dev`; off Linux every control is `unsupported` (declared-audit, OUT of
//! accreditation scope — ADR-0033). The attestation is a **producer claim**
//! folded into a signed startup receipt so an auditor sees the host posture
//! under the org-root key — not a measured/hardware attestation (that is the
//! deferred hardware-root-of-trust track).

use crate::crypto::TrustAnchorPublicKey;
use crate::error::CellosError;
use crate::trust_keys::Signer;
use crate::types::CloudEventV1;
use crate::{sign_event_with, verify_signed_event_envelope, SignedEventEnvelopeV1};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// CloudEvent `type` of a signed host-baseline attestation (S52).
pub const HOST_BASELINE_ATTESTATION_TYPE: &str =
    "dev.cellos.events.cell.host.v1.baseline-attestation";

/// The baseline isolation controls cellos attests, in a stable order.
pub const BASELINE_CONTROLS: &[&str] =
    &["user-namespaces", "cgroup-v2", "seccomp", "nftables", "kvm"];

/// One probed baseline control.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BaselineControl {
    /// Control name (one of [`BASELINE_CONTROLS`]).
    pub name: String,
    /// Whether the primitive was found present on this host.
    pub satisfied: bool,
    /// Human-legible probe detail (the path checked / why it failed).
    pub detail: String,
}

/// Structured host-baseline attestation (S52).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HostBaselineAttestationV1 {
    /// Document format version.
    pub schema_version: String,
    /// `linux-probed` (real `/proc`+`/sys`+`/dev` checks) or
    /// `declared-unsupported` (off-Linux — declared-audit, out of scope).
    pub probe_mode: String,
    /// Per-control results, in [`BASELINE_CONTROLS`] order.
    pub controls: Vec<BaselineControl>,
    /// True iff every control is satisfied.
    pub all_satisfied: bool,
}

#[cfg(target_os = "linux")]
fn probe_mode() -> &'static str {
    "linux-probed"
}
#[cfg(not(target_os = "linux"))]
fn probe_mode() -> &'static str {
    "declared-unsupported"
}

#[cfg(target_os = "linux")]
fn probe_one(name: &str) -> (bool, String) {
    use std::path::Path;
    let exists = |p: &str| {
        if Path::new(p).exists() {
            (true, format!("{p} present"))
        } else {
            (false, format!("{p} absent"))
        }
    };
    match name {
        "user-namespaces" => exists("/proc/self/ns/user"),
        "cgroup-v2" => exists("/sys/fs/cgroup/cgroup.controllers"),
        "seccomp" => match std::fs::read_to_string("/proc/self/status") {
            Ok(s) if s.contains("Seccomp:") => (
                true,
                "/proc/self/status carries a Seccomp field".to_string(),
            ),
            Ok(_) => (false, "no Seccomp field in /proc/self/status".to_string()),
            Err(e) => (false, format!("read /proc/self/status: {e}")),
        },
        // Coarse availability probe: the netfilter subsystem is present. Not a
        // guarantee nft rules can be installed (that needs CAP_NET_ADMIN), but a
        // host without it cannot enforce the egress boundary at all.
        "nftables" => exists("/proc/sys/net/netfilter"),
        "kvm" => exists("/dev/kvm"),
        _ => (false, "unknown control".to_string()),
    }
}
#[cfg(not(target_os = "linux"))]
fn probe_one(_name: &str) -> (bool, String) {
    (
        false,
        "not a Linux host — declared-audit, out of accreditation scope (ADR-0033)".to_string(),
    )
}

/// Probe the host baseline. cellos-core is env-pure (doctrine D11), so the
/// test-override for the unsatisfied path is passed explicitly via
/// [`probe_host_baseline_with_forced`] — the supervisor reads its env knob and
/// forwards the list. This no-arg form probes the real host.
#[must_use]
pub fn probe_host_baseline() -> HostBaselineAttestationV1 {
    probe_host_baseline_with_forced(&[])
}

/// Probe the host baseline, forcing each control named in `forced_missing` to
/// `satisfied = false` (for exercising the unsatisfied/fail-stop path without a
/// host that actually lacks the primitive). `forced_missing` is supplied by the
/// caller — cellos-core itself reads no process env (doctrine D11).
#[must_use]
pub fn probe_host_baseline_with_forced(forced_missing: &[&str]) -> HostBaselineAttestationV1 {
    let controls: Vec<BaselineControl> = BASELINE_CONTROLS
        .iter()
        .map(|&name| {
            let force = forced_missing.contains(&name);
            let (satisfied, detail) = if force {
                (false, "forced missing (test override)".to_string())
            } else {
                probe_one(name)
            };
            BaselineControl {
                name: name.to_string(),
                satisfied,
                detail,
            }
        })
        .collect();
    let all_satisfied = controls.iter().all(|c| c.satisfied);

    HostBaselineAttestationV1 {
        schema_version: "1.0.0".to_string(),
        probe_mode: probe_mode().to_string(),
        controls,
        all_satisfied,
    }
}

/// Build the (unsigned) CloudEvent carrying a host-baseline attestation (S52).
///
/// Shared by [`sign_host_baseline_attestation`] and the supervisor's signed
/// startup-receipt path (C01) so both emit a byte-identical event body — the
/// supervisor routes this event through its standalone signer (redact-before-
/// sign, same transport as admission receipts) rather than re-deriving the
/// envelope here.
///
/// # Errors
///
/// Returns [`CellosError`] if the attestation fails to serialize.
pub fn host_baseline_event(
    attestation: &HostBaselineAttestationV1,
) -> Result<CloudEventV1, CellosError> {
    let data = serde_json::to_value(attestation).map_err(|e| {
        CellosError::InvalidSpec(format!("host-baseline attestation: serialize: {e}"))
    })?;
    Ok(CloudEventV1 {
        specversion: "1.0".into(),
        id: "host-baseline".into(),
        source: "cellos-supervisor".into(),
        ty: HOST_BASELINE_ATTESTATION_TYPE.into(),
        datacontenttype: Some("application/json".into()),
        data: Some(data),
        time: None,
        traceparent: None,
        cex: None,
    })
}

/// Sign a host-baseline attestation as an offline-verifiable startup receipt
/// (S52). Verifiable with only the org-root public key.
///
/// # Errors
///
/// Returns [`CellosError`] if signing fails.
pub fn sign_host_baseline_attestation(
    signer: &dyn Signer,
    attestation: &HostBaselineAttestationV1,
) -> Result<SignedEventEnvelopeV1, CellosError> {
    let event = host_baseline_event(attestation)?;
    sign_event_with(signer, &event)
}

/// Verify a signed host-baseline attestation offline and return the parsed
/// attestation (S52).
///
/// # Errors
///
/// Returns [`CellosError`] on signature failure or a malformed payload.
pub fn verify_host_baseline_attestation(
    envelope: &SignedEventEnvelopeV1,
    verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
) -> Result<HostBaselineAttestationV1, CellosError> {
    let hmac: HashMap<String, Vec<u8>> = HashMap::new();
    let event = verify_signed_event_envelope(envelope, verifying_keys, &hmac)?;
    let data = event
        .data
        .as_ref()
        .ok_or_else(|| CellosError::InvalidSpec("host-baseline: missing data".into()))?;
    serde_json::from_value(data.clone())
        .map_err(|e| CellosError::InvalidSpec(format!("host-baseline: parse: {e}")))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::SoftwareSigner;

    #[test]
    fn probe_enumerates_all_baseline_controls() {
        let att = probe_host_baseline();
        assert_eq!(att.controls.len(), BASELINE_CONTROLS.len());
        for (c, expected) in att.controls.iter().zip(BASELINE_CONTROLS) {
            assert_eq!(&c.name, expected, "controls are in the stable order");
        }
    }

    #[test]
    fn forced_missing_control_is_unsatisfied_and_blocks_all() {
        let att = probe_host_baseline_with_forced(&["seccomp"]);
        let seccomp = att.controls.iter().find(|c| c.name == "seccomp").unwrap();
        assert!(!seccomp.satisfied, "forced control must be unsatisfied");
        assert!(seccomp.detail.contains("forced"));
        assert!(
            !att.all_satisfied,
            "a single missing control blocks all_satisfied"
        );
    }

    #[test]
    fn host_baseline_event_carries_attestation_and_type() {
        let att = probe_host_baseline();
        let event = host_baseline_event(&att).expect("build event");
        assert_eq!(event.ty, HOST_BASELINE_ATTESTATION_TYPE);
        let parsed: HostBaselineAttestationV1 =
            serde_json::from_value(event.data.expect("data present")).expect("parse back");
        assert_eq!(parsed, att, "event body round-trips to the attestation");
    }

    #[test]
    fn attestation_signs_and_verifies_offline() {
        let att = probe_host_baseline();
        let signer = SoftwareSigner::from_seed("org-root-2026", [8u8; 32]).unwrap();
        let envelope = sign_host_baseline_attestation(&signer, &att).unwrap();

        use crate::trust_keys::Signer as _;
        let mut keys = HashMap::new();
        keys.insert("org-root-2026".to_string(), signer.verifying_key());
        let verified = verify_host_baseline_attestation(&envelope, &keys)
            .expect("host-baseline attestation verifies offline");
        assert_eq!(verified, att, "round-trips through sign+verify");

        // tamper -> verify fails
        let mut tampered = envelope;
        tampered.event.id = "tampered".into();
        assert!(verify_host_baseline_attestation(&tampered, &keys).is_err());
    }
}