use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EvidenceTier {
HostFact,
Constrained,
SelfReport,
}
pub type TrustLevel = EvidenceTier;
impl PartialOrd for EvidenceTier {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for EvidenceTier {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.priority().cmp(&other.priority())
}
}
impl EvidenceTier {
#[inline]
pub fn priority(&self) -> u8 {
match self {
EvidenceTier::HostFact => 3,
EvidenceTier::Constrained => 2,
EvidenceTier::SelfReport => 1,
}
}
#[inline]
pub fn can_support_pass(&self) -> bool {
matches!(self, EvidenceTier::HostFact)
}
#[inline]
pub fn is_proof(&self) -> bool {
matches!(self, EvidenceTier::HostFact | EvidenceTier::Constrained)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum EvidenceSource {
HostObservation,
ConstrainedChannel,
AgentSelfReport,
ProcessStdout,
ProcessStderr,
SandboxedArbitraryJson,
UnprovenancedSnapshot,
UnboundObservation,
}
impl EvidenceSource {
pub fn tier(&self) -> EvidenceTier {
match self {
EvidenceSource::HostObservation => EvidenceTier::HostFact,
EvidenceSource::ConstrainedChannel => EvidenceTier::Constrained,
EvidenceSource::AgentSelfReport
| EvidenceSource::ProcessStdout
| EvidenceSource::ProcessStderr
| EvidenceSource::SandboxedArbitraryJson
| EvidenceSource::UnprovenancedSnapshot
| EvidenceSource::UnboundObservation => EvidenceTier::SelfReport,
}
}
pub fn counts_as_proof(&self) -> bool {
match self {
EvidenceSource::HostObservation | EvidenceSource::ConstrainedChannel => true,
EvidenceSource::AgentSelfReport
| EvidenceSource::ProcessStdout
| EvidenceSource::ProcessStderr
| EvidenceSource::SandboxedArbitraryJson
| EvidenceSource::UnprovenancedSnapshot
| EvidenceSource::UnboundObservation => false,
}
}
pub fn can_support_pass(&self) -> bool {
matches!(self, EvidenceSource::HostObservation)
}
}
pub const HOST_CONTROL_CHANNEL: &str = "host-challenge-v1";
pub const HOST_CONTROL_FACT: &str = "control";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionIdentity {
pub scenario_id: String,
pub session_nonce: String,
pub registry_hash: String,
pub frozen_hash: String,
#[serde(default)]
pub contract_digest: Option<String>,
}
impl ExecutionIdentity {
pub fn new(
scenario_id: &str,
session_nonce: &str,
registry_hash: &str,
frozen_hash: &str,
) -> Self {
ExecutionIdentity {
scenario_id: scenario_id.to_string(),
session_nonce: session_nonce.to_string(),
registry_hash: registry_hash.to_string(),
frozen_hash: frozen_hash.to_string(),
contract_digest: None,
}
}
pub fn with_contract_digest(mut self, contract_digest: &str) -> Self {
self.contract_digest = Some(contract_digest.to_string());
self
}
pub fn execution_id(&self) -> String {
format!(
"{}:{}:{}",
self.scenario_id, self.session_nonce, self.frozen_hash
)
}
pub fn is_well_formed(&self) -> bool {
!self.scenario_id.is_empty()
&& !self.session_nonce.is_empty()
&& !self.registry_hash.is_empty()
&& !self.frozen_hash.is_empty()
&& self
.contract_digest
.as_ref()
.map_or(true, |d| !d.is_empty())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostProvenance {
pub scenario_id: String,
pub session_nonce: String,
pub registry_hash: String,
pub frozen_hash: String,
pub channel: String,
#[serde(default)]
pub contract_digest: Option<String>,
}
impl HostProvenance {
pub fn matches(&self, identity: &ExecutionIdentity) -> bool {
self.channel == HOST_CONTROL_CHANNEL
&& self.scenario_id == identity.scenario_id
&& self.session_nonce == identity.session_nonce
&& self.registry_hash == identity.registry_hash
&& self.frozen_hash == identity.frozen_hash
&& match (&self.contract_digest, &identity.contract_digest) {
(Some(p_dig), Some(id_dig)) => p_dig == id_dig,
(Some(_), None) => false,
(None, Some(_)) => false,
(None, None) => true,
}
}
}
#[derive(Debug, Clone)]
pub struct VerifiedControl {
identity: ExecutionIdentity,
}
impl VerifiedControl {
pub fn identity(&self) -> &ExecutionIdentity {
&self.identity
}
}
pub fn derive_expected_response(challenge: &str, session_nonce: &str) -> String {
let combined = format!("{challenge}{session_nonce}");
let bytes = combined.as_bytes();
if bytes.len() <= 8 {
return combined;
}
let (head, tail) = combined.split_at(bytes.len() - 8);
format!("{tail}{head}")
}
pub fn attest_control(
identity: &ExecutionIdentity,
expected: &str,
received: &[u8],
) -> Option<VerifiedControl> {
if !identity.is_well_formed() {
return None;
}
if expected.is_empty() {
return None;
}
if received.len() != expected.len() {
return None;
}
if received != expected.as_bytes() {
return None;
}
Some(VerifiedControl {
identity: identity.clone(),
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fact {
pub tier: EvidenceTier,
pub name: String,
pub value: String,
#[serde(default)]
pub provenance: Option<HostProvenance>,
#[serde(default)]
pub source: Option<EvidenceSource>,
#[serde(default)]
pub timestamp_epoch_ms: Option<u64>,
}
impl Fact {
pub fn counts_as_proof(&self) -> bool {
if let Some(src) = self.source {
if !src.counts_as_proof() {
return false;
}
}
self.tier.is_proof()
}
pub fn can_support_pass(&self) -> bool {
if let Some(src) = self.source {
if !src.can_support_pass() {
return false;
}
}
self.tier.can_support_pass()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Evidence {
pub facts: Vec<Fact>,
}
impl Evidence {
fn current_timestamp_ms() -> Option<u64> {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_millis() as u64)
}
pub fn push(&mut self, tier: EvidenceTier, name: &str, value: String) {
let source = match tier {
EvidenceTier::HostFact => Some(EvidenceSource::HostObservation),
EvidenceTier::Constrained => Some(EvidenceSource::ConstrainedChannel),
EvidenceTier::SelfReport => Some(EvidenceSource::AgentSelfReport),
};
self.facts.push(Fact {
tier,
name: name.to_string(),
value,
provenance: None,
source,
timestamp_epoch_ms: Self::current_timestamp_ms(),
});
}
pub fn push_with_source(
&mut self,
tier: EvidenceTier,
source: EvidenceSource,
name: &str,
value: String,
) {
let effective_tier = if !source.counts_as_proof() {
EvidenceTier::SelfReport
} else {
tier.min(source.tier())
};
self.facts.push(Fact {
tier: effective_tier,
name: name.to_string(),
value,
provenance: None,
source: Some(source),
timestamp_epoch_ms: Self::current_timestamp_ms(),
});
}
pub fn host_fact(&mut self, name: &str, value: String) {
self.push(EvidenceTier::HostFact, name, value);
}
pub fn constrained(&mut self, name: &str, value: String) {
self.push(EvidenceTier::Constrained, name, value);
}
pub fn self_report(&mut self, name: &str, value: String) {
self.push(EvidenceTier::SelfReport, name, value);
}
pub fn add_stdout(&mut self, name: &str, value: String) {
self.push_with_source(
EvidenceTier::SelfReport,
EvidenceSource::ProcessStdout,
name,
value,
);
}
pub fn add_arbitrary_json(&mut self, name: &str, value: String) {
self.push_with_source(
EvidenceTier::SelfReport,
EvidenceSource::SandboxedArbitraryJson,
name,
value,
);
}
pub fn add_unprovenanced_snapshot(&mut self, name: &str, value: String) {
self.push_with_source(
EvidenceTier::SelfReport,
EvidenceSource::UnprovenancedSnapshot,
name,
value,
);
}
pub fn add_unbound_observation(&mut self, name: &str, value: String) {
self.push_with_source(
EvidenceTier::SelfReport,
EvidenceSource::UnboundObservation,
name,
value,
);
}
pub fn host_control_fact(&mut self, verified: &VerifiedControl) {
let id = verified.identity();
self.facts.push(Fact {
tier: EvidenceTier::HostFact,
name: HOST_CONTROL_FACT.to_string(),
value: "host-observed:verified".to_string(),
provenance: Some(HostProvenance {
scenario_id: id.scenario_id.clone(),
session_nonce: id.session_nonce.clone(),
registry_hash: id.registry_hash.clone(),
frozen_hash: id.frozen_hash.clone(),
channel: HOST_CONTROL_CHANNEL.to_string(),
contract_digest: id.contract_digest.clone(),
}),
source: Some(EvidenceSource::HostObservation),
timestamp_epoch_ms: Self::current_timestamp_ms(),
});
}
pub fn has_verified_control(&self, identity: &ExecutionIdentity) -> bool {
self.facts.iter().any(|f| {
f.tier == EvidenceTier::HostFact
&& f.can_support_pass()
&& f.name == HOST_CONTROL_FACT
&& f.provenance.as_ref().is_some_and(|p| p.matches(identity))
})
}
pub fn has_host_fact(&self) -> bool {
self.facts
.iter()
.any(|f| f.tier == EvidenceTier::HostFact && f.can_support_pass())
}
pub fn host_fact_value(&self, name: &str) -> Option<&str> {
self.facts
.iter()
.find(|f| f.tier == EvidenceTier::HostFact && f.name == name && f.can_support_pass())
.map(|f| f.value.as_str())
}
pub fn verify_integrity(&self) -> bool {
for fact in &self.facts {
if let Some(src) = fact.source {
if !src.counts_as_proof() && fact.tier != EvidenceTier::SelfReport {
return false;
}
if fact.tier > src.tier() {
return false;
}
}
if fact.name == HOST_CONTROL_FACT && fact.tier == EvidenceTier::HostFact {
match &fact.provenance {
Some(prov) if prov.channel == HOST_CONTROL_CHANNEL => {}
_ => return false,
}
}
}
true
}
}
#[cfg(test)]
mod evidence_tests {
use super::*;
#[test]
fn pass_requires_host_fact() {
let mut e = Evidence::default();
e.self_report("marker", "PASS".to_string());
e.constrained("errno", "EACCES".to_string());
assert!(!e.has_host_fact());
e.host_fact("postmortem", "absent".to_string());
assert!(e.has_host_fact());
}
#[test]
fn evidence_tier_trust_hierarchy_ordering() {
assert!(EvidenceTier::HostFact > EvidenceTier::Constrained);
assert!(EvidenceTier::Constrained > EvidenceTier::SelfReport);
assert!(EvidenceTier::HostFact > EvidenceTier::SelfReport);
assert_eq!(EvidenceTier::HostFact.priority(), 3);
assert_eq!(EvidenceTier::Constrained.priority(), 2);
assert_eq!(EvidenceTier::SelfReport.priority(), 1);
assert!(EvidenceTier::HostFact.can_support_pass());
assert!(!EvidenceTier::Constrained.can_support_pass());
assert!(!EvidenceTier::SelfReport.can_support_pass());
assert!(EvidenceTier::HostFact.is_proof());
assert!(EvidenceTier::Constrained.is_proof());
assert!(!EvidenceTier::SelfReport.is_proof());
}
#[test]
fn evidence_source_classification_and_proof_rules() {
let untrusted = [
EvidenceSource::AgentSelfReport,
EvidenceSource::ProcessStdout,
EvidenceSource::ProcessStderr,
EvidenceSource::SandboxedArbitraryJson,
EvidenceSource::UnprovenancedSnapshot,
EvidenceSource::UnboundObservation,
];
for src in untrusted {
assert!(!src.counts_as_proof(), "{src:?} must never count as proof");
assert!(!src.can_support_pass(), "{src:?} must never support PASS");
assert_eq!(src.tier(), EvidenceTier::SelfReport);
}
assert!(EvidenceSource::HostObservation.counts_as_proof());
assert!(EvidenceSource::HostObservation.can_support_pass());
assert_eq!(
EvidenceSource::HostObservation.tier(),
EvidenceTier::HostFact
);
assert!(EvidenceSource::ConstrainedChannel.counts_as_proof());
assert!(!EvidenceSource::ConstrainedChannel.can_support_pass());
assert_eq!(
EvidenceSource::ConstrainedChannel.tier(),
EvidenceTier::Constrained
);
}
#[test]
fn evidence_push_with_source_enforces_hierarchy() {
let mut e = Evidence::default();
e.push_with_source(
EvidenceTier::HostFact,
EvidenceSource::ProcessStdout,
"fake_host",
"escaped".to_string(),
);
assert_eq!(e.facts.last().unwrap().tier, EvidenceTier::SelfReport);
assert!(!e.has_host_fact());
e.add_arbitrary_json("status", "{\"verdict\": \"PASS\"}".to_string());
assert_eq!(e.facts.last().unwrap().tier, EvidenceTier::SelfReport);
assert!(!e.has_host_fact());
e.add_unprovenanced_snapshot("fs_tree", "/".to_string());
assert_eq!(e.facts.last().unwrap().tier, EvidenceTier::SelfReport);
assert!(!e.has_host_fact());
e.add_unbound_observation("event", "open".to_string());
assert_eq!(e.facts.last().unwrap().tier, EvidenceTier::SelfReport);
assert!(!e.has_host_fact());
}
#[test]
fn evidence_integrity_rejects_escalated_source() {
let mut e = Evidence::default();
e.host_fact("valid", "ok".to_string());
assert!(e.verify_integrity());
e.facts.push(Fact {
tier: EvidenceTier::HostFact,
name: "forged".to_string(),
value: "hack".to_string(),
provenance: None,
source: Some(EvidenceSource::SandboxedArbitraryJson),
timestamp_epoch_ms: None,
});
assert!(!e.verify_integrity());
assert!(!e.facts.last().unwrap().can_support_pass());
}
fn test_identity() -> ExecutionIdentity {
ExecutionIdentity::new("SCEN-A", "nonce-a", "reg-a", "frozen-a")
}
const TEST_CHALLENGE: &str = "0123456789abcdef0123456789abcdef";
fn test_expected() -> (ExecutionIdentity, String) {
let id = test_identity();
let expected = derive_expected_response(TEST_CHALLENGE, &id.session_nonce);
(id, expected)
}
#[test]
fn response_rotates_challenge_plus_nonce() {
let r = derive_expected_response(TEST_CHALLENGE, "nonce-a");
let s = format!("{TEST_CHALLENGE}nonce-a");
assert_eq!(r.len(), s.len());
assert_eq!(r, format!("{}{}", &s[s.len() - 8..], &s[..s.len() - 8]));
assert_eq!(derive_expected_response(TEST_CHALLENGE, "nonce-a"), r);
assert_ne!(
derive_expected_response("fedcba9876543210fedcba9876543210", "nonce-a"),
r
);
assert_ne!(derive_expected_response(TEST_CHALLENGE, "nonce-b"), r);
}
#[test]
fn echo_is_never_the_response() {
let r = derive_expected_response(TEST_CHALLENGE, "nonce-a");
assert_ne!(TEST_CHALLENGE, r, "challenge echo is not the response");
assert_ne!("nonce-a", r, "nonce echo is not the response");
assert_ne!(
format!("{TEST_CHALLENGE}nonce-a"),
r,
"unrotated concatenation is not the response"
);
}
#[test]
fn attest_mints_only_on_exact_response() {
let (id, expected) = test_expected();
assert!(attest_control(&id, &expected, expected.as_bytes()).is_some());
assert!(attest_control(&id, &expected, TEST_CHALLENGE.as_bytes()).is_none());
assert!(attest_control(&id, &expected, b"nonce-a").is_none());
let plain = format!("{TEST_CHALLENGE}nonce-a");
assert!(attest_control(&id, &expected, plain.as_bytes()).is_none());
assert!(attest_control(&id, &expected, b"forged").is_none());
assert!(attest_control(&id, &expected, b"").is_none());
let mut short = expected.as_bytes().to_vec();
short.pop();
assert!(attest_control(&id, &expected, &short).is_none());
let mut doubled = expected.as_bytes().to_vec();
doubled.extend_from_slice(expected.as_bytes());
assert!(attest_control(&id, &expected, &doubled).is_none());
let bad = ExecutionIdentity::new("", "n", "r", "f");
assert!(!bad.is_well_formed());
assert!(attest_control(&bad, &expected, expected.as_bytes()).is_none());
assert!(attest_control(&id, "", expected.as_bytes()).is_none());
}
#[test]
fn verified_control_fact_matches_only_own_identity() {
let (id, expected) = test_expected();
let verified = attest_control(&id, &expected, expected.as_bytes()).expect("mint");
let mut e = Evidence::default();
e.host_fact("wait-status", "exit=0".to_string());
assert!(!e.has_verified_control(&id));
e.host_control_fact(&verified);
assert!(e.has_verified_control(&id));
let fact = e
.facts
.iter()
.find(|f| f.name == HOST_CONTROL_FACT)
.expect("control fact");
assert!(!fact.value.contains(&expected));
assert!(!fact.value.contains(TEST_CHALLENGE));
let mut other = id.clone();
other.session_nonce = "nonce-b".to_string();
assert!(!e.has_verified_control(&other));
let mut other = id.clone();
other.scenario_id = "SCEN-B".to_string();
assert!(!e.has_verified_control(&other));
let mut other = id.clone();
other.registry_hash = "reg-b".to_string();
assert!(!e.has_verified_control(&other));
let mut other = id.clone();
other.frozen_hash = "frozen-b".to_string();
assert!(!e.has_verified_control(&other));
}
#[test]
fn contract_digest_binding_in_identity_and_provenance() {
let id_with_digest = ExecutionIdentity::new("SCEN-A", "nonce-a", "reg-a", "frozen-a")
.with_contract_digest("digest-123");
assert_eq!(
id_with_digest.contract_digest.as_deref(),
Some("digest-123")
);
assert!(id_with_digest.is_well_formed());
let expected = derive_expected_response(TEST_CHALLENGE, &id_with_digest.session_nonce);
let verified =
attest_control(&id_with_digest, &expected, expected.as_bytes()).expect("mint");
let mut e = Evidence::default();
e.host_control_fact(&verified);
assert!(e.has_verified_control(&id_with_digest));
let mismatched_digest = id_with_digest
.clone()
.with_contract_digest("digest-TAMPERED");
assert!(!e.has_verified_control(&mismatched_digest));
let no_digest_id = test_identity();
assert!(!e.has_verified_control(&no_digest_id));
}
}