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;
pub const CROSS_HOST_ENTRY_TYPE: &str = "dev.cellos.events.cell.audit.v1.cross-host-entry";
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
}
pub fn entry_digest(event: &CloudEventV1) -> Result<String, CellosError> {
let bytes = canonical_event_signing_payload(event)?;
Ok(hex(&provider().sha256(&bytes)))
}
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)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedCrossHostChains {
pub per_node_high_seq: HashMap<String, u64>,
}
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 {
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,
});
}
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
}
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);
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); 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);
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(); 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"
);
}
}