cellos-core 0.8.0-pre

CellOS domain types and ports — typed authority, formation DAG, CloudEvent envelopes, RBAC primitives. No I/O.
Documentation
//! Cross-host tamper-evident audit chain (S28, ADR-0030).
//!
//! Each node maintains its own monotonic `(seq, prev_digest)` hash chain of
//! signed entries. An offline auditor holding only the org-root verifying keys
//! can take an **interleaved** stream of entries from N nodes and detect, per
//! node, any dropped, reordered, or tampered entry — no network, no producer
//! trust. Each entry is a [`crate::SignedEventEnvelopeV1`] (so it rides the same
//! offline-verifiable seam as every other cellos receipt), and entries link via
//! a sha256 digest over the canonical signing payload of the prior entry.

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

/// CloudEvent `type` of a cross-host chain entry (S28).
pub const CROSS_HOST_ENTRY_TYPE: &str = "dev.cellos.events.cell.audit.v1.cross-host-entry";

/// `prev_digest` value for the genesis (`seq == 0`) entry of each node's chain.
pub const GENESIS_PREV_DIGEST: &str =
    "0000000000000000000000000000000000000000000000000000000000000000";

fn hex(bytes: &[u8]) -> String {
    use std::fmt::Write as _;
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        let _ = write!(s, "{b:02x}");
    }
    s
}

/// The chain digest of an entry's signed CloudEvent: sha256 (via the active
/// [`crate::crypto::CryptoProvider`]) over its canonical signing payload — the
/// value the NEXT entry in that node's chain references as `prev_digest`.
///
/// # Errors
///
/// Returns [`CellosError`] if the event cannot be canonicalized.
pub fn entry_digest(event: &CloudEventV1) -> Result<String, CellosError> {
    let bytes = canonical_event_signing_payload(event)?;
    Ok(hex(&provider().sha256(&bytes)))
}

/// Build + sign one cross-host chain entry for `node_id` at `seq`, linking to
/// `prev_digest` (use [`GENESIS_PREV_DIGEST`] for `seq == 0`). `payload` is the
/// operational event data being chained. The returned envelope's
/// [`entry_digest`] is what the node's next entry must reference.
///
/// # Errors
///
/// Returns [`CellosError`] if signing fails.
pub fn build_chain_entry(
    signer: &dyn Signer,
    node_id: &str,
    seq: u64,
    prev_digest: &str,
    payload: &serde_json::Value,
) -> Result<SignedEventEnvelopeV1, CellosError> {
    let event = CloudEventV1 {
        specversion: "1.0".into(),
        id: format!("{node_id}-{seq}"),
        source: format!("cellos-node:{node_id}"),
        ty: CROSS_HOST_ENTRY_TYPE.into(),
        datacontenttype: Some("application/json".into()),
        data: Some(serde_json::json!({
            "nodeId": node_id,
            "seq": seq,
            "prevDigest": prev_digest,
            "payload": payload,
        })),
        time: None,
        traceparent: None,
        cex: None,
    };
    sign_event_with(signer, &event)
}

/// Result of [`verify_cross_host_chains`]: the highest verified seq per node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedCrossHostChains {
    /// `node_id -> highest contiguous verified seq` for every node in the stream.
    pub per_node_high_seq: HashMap<String, u64>,
}

/// Verify an interleaved stream of cross-host chain entries offline.
///
/// Every entry's signature is verified under `verifying_keys`; entries are then
/// grouped per node and each node's sub-chain is checked gap-free (seq `0..n`)
/// and link-intact (`prev_digest` references the prior entry's [`entry_digest`]).
/// A dropped, reordered, spliced, or tampered entry fails.
///
/// # Errors
///
/// Returns [`CellosError`] on any signature, gap, or chain-link failure.
pub fn verify_cross_host_chains(
    entries: &[SignedEventEnvelopeV1],
    verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
) -> Result<VerifiedCrossHostChains, CellosError> {
    let hmac: HashMap<String, Vec<u8>> = HashMap::new();

    struct Decoded {
        node: String,
        seq: u64,
        prev: String,
        digest: String,
    }
    let mut decoded: Vec<Decoded> = Vec::with_capacity(entries.len());
    for env in entries {
        // (1) signature must verify offline under the org-root keys.
        let event = verify_signed_event_envelope(env, verifying_keys, &hmac)?;
        let data = event
            .data
            .as_ref()
            .ok_or_else(|| CellosError::InvalidSpec("cross-host entry: missing data".into()))?;
        let field = |k: &str| -> Result<&serde_json::Value, CellosError> {
            data.get(k)
                .ok_or_else(|| CellosError::InvalidSpec(format!("cross-host entry: missing {k}")))
        };
        let node = field("nodeId")?
            .as_str()
            .ok_or_else(|| {
                CellosError::InvalidSpec("cross-host entry: nodeId not a string".into())
            })?
            .to_string();
        let seq = field("seq")?
            .as_u64()
            .ok_or_else(|| CellosError::InvalidSpec("cross-host entry: seq not a u64".into()))?;
        let prev = field("prevDigest")?
            .as_str()
            .ok_or_else(|| {
                CellosError::InvalidSpec("cross-host entry: prevDigest not a string".into())
            })?
            .to_string();
        let digest = entry_digest(event)?;
        decoded.push(Decoded {
            node,
            seq,
            prev,
            digest,
        });
    }

    // (2) group by node, order by seq, check gap-free + linkage.
    let mut by_node: HashMap<String, Vec<&Decoded>> = HashMap::new();
    for d in &decoded {
        by_node.entry(d.node.clone()).or_default().push(d);
    }
    let mut high = HashMap::new();
    for (node, mut v) in by_node {
        v.sort_by_key(|d| d.seq);
        let mut expected_prev = GENESIS_PREV_DIGEST.to_string();
        for (i, d) in v.iter().enumerate() {
            if d.seq != i as u64 {
                return Err(CellosError::InvalidSpec(format!(
                    "cross-host: node {node} seq gap — got {} expected {i}",
                    d.seq
                )));
            }
            if d.prev != expected_prev {
                return Err(CellosError::InvalidSpec(format!(
                    "cross-host: node {node} chain break at seq {} (prevDigest mismatch)",
                    d.seq
                )));
            }
            expected_prev = d.digest.clone();
        }
        high.insert(node, v.len() as u64 - 1);
    }
    Ok(VerifiedCrossHostChains {
        per_node_high_seq: high,
    })
}

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

    fn signer(seed: u8, kid: &str) -> SoftwareSigner {
        SoftwareSigner::from_seed(kid, [seed; 32]).expect("signer")
    }

    fn keyring(signers: &[(&str, &SoftwareSigner)]) -> HashMap<String, TrustAnchorPublicKey> {
        use crate::trust_keys::Signer as _;
        let mut k = HashMap::new();
        for (kid, s) in signers {
            k.insert((*kid).to_string(), s.verifying_key());
        }
        k
    }

    /// Build a node's full chain of `n` entries; returns the signed entries.
    fn node_chain(s: &SoftwareSigner, node: &str, n: u64) -> Vec<SignedEventEnvelopeV1> {
        let mut out = Vec::new();
        let mut prev = GENESIS_PREV_DIGEST.to_string();
        for seq in 0..n {
            let env = build_chain_entry(s, node, seq, &prev, &json!({ "i": seq })).unwrap();
            prev = entry_digest(&env.event).unwrap();
            out.push(env);
        }
        out
    }

    #[test]
    fn interleaved_three_node_stream_verifies_gap_free() {
        let (sa, sb, sc) = (
            signer(1, "node-a"),
            signer(2, "node-b"),
            signer(3, "node-c"),
        );
        let a = node_chain(&sa, "node-a", 3);
        let b = node_chain(&sb, "node-b", 2);
        let c = node_chain(&sc, "node-c", 4);
        // Interleave the three streams.
        let mut stream = Vec::new();
        for i in 0..4 {
            if let Some(e) = a.get(i) {
                stream.push(e.clone());
            }
            if let Some(e) = b.get(i) {
                stream.push(e.clone());
            }
            if let Some(e) = c.get(i) {
                stream.push(e.clone());
            }
        }
        let keys = keyring(&[("node-a", &sa), ("node-b", &sb), ("node-c", &sc)]);
        let v = verify_cross_host_chains(&stream, &keys).expect("interleaved stream verifies");
        assert_eq!(v.per_node_high_seq["node-a"], 2);
        assert_eq!(v.per_node_high_seq["node-b"], 1);
        assert_eq!(v.per_node_high_seq["node-c"], 3);
    }

    #[test]
    fn dropped_middle_entry_is_detected() {
        let sa = signer(1, "node-a");
        let mut a = node_chain(&sa, "node-a", 3);
        a.remove(1); // drop seq 1 -> seq jumps 0 -> 2
        let keys = keyring(&[("node-a", &sa)]);
        let err = verify_cross_host_chains(&a, &keys).expect_err("a drop must be detected");
        assert!(format!("{err}").contains("seq gap"), "got: {err}");
    }

    #[test]
    fn spliced_entry_with_wrong_prev_is_detected() {
        let sa = signer(1, "node-a");
        let mut a = node_chain(&sa, "node-a", 3);
        // Re-sign entry seq 2 with a bogus prevDigest (a splice) — signature is
        // valid but the link is broken.
        a[2] = build_chain_entry(&sa, "node-a", 2, "deadbeef", &json!({ "i": 2 })).unwrap();
        let keys = keyring(&[("node-a", &sa)]);
        let err = verify_cross_host_chains(&a, &keys).expect_err("broken link must be detected");
        assert!(format!("{err}").contains("chain break"), "got: {err}");
    }

    #[test]
    fn tampered_entry_fails_signature() {
        let sa = signer(1, "node-a");
        let mut a = node_chain(&sa, "node-a", 2);
        a[0].event.id = "tampered".into(); // mutate after signing
        let keys = keyring(&[("node-a", &sa)]);
        let err = verify_cross_host_chains(&a, &keys).expect_err("tamper must fail");
        assert!(
            format!("{err}").contains("ed25519 verify failed"),
            "got: {err}"
        );
    }

    #[test]
    fn entry_digest_is_byte_deterministic() {
        let sa = signer(1, "node-a");
        let e1 =
            build_chain_entry(&sa, "node-a", 0, GENESIS_PREV_DIGEST, &json!({ "i": 0 })).unwrap();
        assert_eq!(
            entry_digest(&e1.event).unwrap(),
            entry_digest(&e1.event).unwrap(),
            "digest is deterministic"
        );
    }
}