Skip to main content

inherence_verifier/
payload.rs

1//! Payload schema + cross-block consistency checks.
2
3use serde_json::Value;
4
5use crate::error::VerifyError;
6use crate::VerifyConfig;
7
8const ACCEPTED_SCHEMA_VERSIONS: &[&str] = &["inherence/1.6", "inherence/2.0"];
9const REQUIRED_TOP: &[&str] = &[
10    "iss", "sub", "aud", "iat", "exp", "jti", "schema_version", "verification", "claims",
11];
12const REQUIRED_VERIFICATION: &[&str] = &[
13    "trust_framework", "verification_process", "policy", "evidence", "attestor",
14];
15const REQUIRED_CLAIMS: &[&str] = &[
16    "service_id", "operation_type", "input_hash", "output_hash",
17];
18const CONTRACT_STATES: &[&str] = &[
19    "Quoted", "Accepted", "InProgress", "Delivered", "Verified", "Disputed", "Terminated",
20];
21
22pub fn validate_shape(p: &Value) -> Result<(), VerifyError> {
23    let obj = p.as_object().ok_or_else(|| sv("payload is not an object"))?;
24    for k in REQUIRED_TOP {
25        if !obj.contains_key(*k) {
26            return Err(sv(&format!("missing top-level {k:?}")));
27        }
28    }
29    let sv_str = obj["schema_version"].as_str().ok_or_else(|| sv("schema_version not a string"))?;
30    if !ACCEPTED_SCHEMA_VERSIONS.contains(&sv_str) {
31        return Err(sv(&format!("schema_version {sv_str:?} not in accepted set")));
32    }
33    let verif = obj["verification"].as_object().ok_or_else(|| sv("verification not an object"))?;
34    for k in REQUIRED_VERIFICATION {
35        if !verif.contains_key(*k) {
36            return Err(sv(&format!("verification missing {k:?}")));
37        }
38    }
39    let evidence = verif["evidence"].as_array().ok_or_else(|| sv("evidence not an array"))?;
40    if evidence.len() < 2 {
41        return Err(sv("evidence must have at least 2 entries"));
42    }
43    let has_pe = evidence.iter().any(|e| e.get("type").and_then(|v| v.as_str()) == Some("policy_evaluation"));
44    let has_cp = evidence.iter().any(|e| e.get("type").and_then(|v| v.as_str()) == Some("cryptographic_proof"));
45    if !has_pe { return Err(sv("evidence missing policy_evaluation")); }
46    if !has_cp { return Err(sv("evidence missing cryptographic_proof")); }
47
48    let claims = obj["claims"].as_object().ok_or_else(|| sv("claims not an object"))?;
49    for k in REQUIRED_CLAIMS {
50        if !claims.contains_key(*k) {
51            return Err(sv(&format!("claims missing {k:?}")));
52        }
53    }
54
55    if is_v2(p) {
56        let contract = verif.get("contract").and_then(|v| v.as_object())
57            .ok_or_else(|| sv("v2 receipt missing verification.contract"))?;
58        for k in &["contract_id", "contract_hash", "state", "principal_signatures", "predicate_witness"] {
59            if !contract.contains_key(*k) {
60                return Err(sv(&format!("verification.contract missing {k:?}")));
61            }
62        }
63        let state = contract["state"].as_str().unwrap_or("");
64        if !CONTRACT_STATES.contains(&state) {
65            return Err(sv(&format!("contract.state {state:?} not canonical")));
66        }
67        let witness = contract["predicate_witness"].as_object()
68            .ok_or_else(|| sv("predicate_witness not an object"))?;
69        for (k, v) in witness {
70            if !v.is_boolean() {
71                return Err(sv(&format!("predicate_witness[{k:?}] not boolean")));
72            }
73        }
74
75        let pi = verif.get("public_inputs").and_then(|v| v.as_object())
76            .ok_or_else(|| sv("v2 receipt missing verification.public_inputs"))?;
77        for k in &["action_hash", "contract_hash", "decision_bit", "vk_hash"] {
78            if !pi.contains_key(*k) {
79                return Err(sv(&format!("public_inputs missing {k:?}")));
80            }
81        }
82        let db = pi["decision_bit"].as_i64().unwrap_or(-1);
83        if db != 0 && db != 1 {
84            return Err(sv(&format!("decision_bit must be 0 or 1, got {db}")));
85        }
86    }
87
88    Ok(())
89}
90
91pub fn check_temporal(p: &Value, cfg: &VerifyConfig) -> Result<(), VerifyError> {
92    let iat = p["iat"].as_u64().ok_or_else(|| sv("iat not unsigned int"))?;
93    let exp = p["exp"].as_u64().ok_or_else(|| sv("exp not unsigned int"))?;
94    let now = cfg.now();
95    let skew = cfg.skew();
96    if now + skew < iat {
97        return Err(VerifyError::NotYetValid);
98    }
99    if exp + skew < now {
100        return Err(VerifyError::Expired);
101    }
102    Ok(())
103}
104
105pub fn check_issuer_audience(p: &Value) -> Result<(), VerifyError> {
106    let iss = p["iss"].as_str().unwrap_or("");
107    if iss != "https://inherence.dev/v1" {
108        return Err(VerifyError::WrongIssuer(iss.to_string()));
109    }
110    let aud = p["aud"].as_str().unwrap_or("");
111    if aud != "audit" {
112        return Err(VerifyError::WrongAudience(aud.to_string()));
113    }
114    Ok(())
115}
116
117pub fn is_v2(p: &Value) -> bool {
118    p.get("schema_version").and_then(|v| v.as_str())
119        .map(|s| s.starts_with("inherence/2."))
120        .unwrap_or(false)
121}
122
123pub fn check_cross_block_contract_hash(p: &Value) -> Result<(), VerifyError> {
124    let cb = &p["verification"]["contract"]["contract_hash"];
125    let pi = &p["verification"]["public_inputs"]["contract_hash"];
126    if cb != pi {
127        return Err(VerifyError::ContractHashMismatch);
128    }
129    Ok(())
130}
131
132pub fn check_decision_bit_consistency(p: &Value) -> Result<(), VerifyError> {
133    let decision = p["claims"]["agent_decision"].as_str().unwrap_or("");
134    let bit = p["verification"]["public_inputs"]["decision_bit"].as_i64().unwrap_or(-1);
135    let expected = match decision { "allow" => 1, "deny" => 0, _ => -1 };
136    if bit != expected {
137        return Err(VerifyError::DecisionBitMismatch);
138    }
139    Ok(())
140}
141
142pub fn check_state_machine(p: &Value) -> Result<(), VerifyError> {
143    let state = p["verification"]["contract"]["state"].as_str().unwrap_or("");
144    let decision = p["claims"]["agent_decision"].as_str().unwrap_or("");
145    // Strict-mode check (SPEC ยง6.2, V19): in revoked-scope states,
146    // no decision should be allow.
147    if (state == "Terminated" || state == "Disputed") && decision == "allow" {
148        return Err(VerifyError::StateInvalid(
149            format!("decision=allow not permitted in state {state:?}")));
150    }
151    Ok(())
152}
153
154pub fn check_vk_pin(p: &Value, cfg: &VerifyConfig) -> Result<(), VerifyError> {
155    let vk = p["verification"]["public_inputs"]["vk_hash"].as_str().unwrap_or("");
156    if !cfg.knows_vk(vk) {
157        return Err(VerifyError::UnknownVk(vk.to_string()));
158    }
159    Ok(())
160}
161
162#[cfg(feature = "eip712")]
163pub fn check_principal_signatures(p: &Value) -> Result<(), VerifyError> {
164    use crate::eip712;
165    let contract = &p["verification"]["contract"];
166    let contract_hash_hex = contract["contract_hash"].as_str().unwrap_or("");
167    let sigs = &contract["principal_signatures"];
168    for &role in &["principal", "agent"] {
169        let did = sigs[role]["did"].as_str().unwrap_or("");
170        let sig_hex = sigs[role]["signature"].as_str().unwrap_or("");
171        match eip712::recover_address(contract_hash_hex, sig_hex) {
172            Ok(recovered) => {
173                let expected = eip712::address_from_did_ethr(did);
174                if expected.eq_ignore_ascii_case(&recovered) {
175                    continue;
176                }
177                return Err(VerifyError::PrincipalSignatureInvalid(role_str(role)));
178            }
179            Err(_) => return Err(VerifyError::PrincipalSignatureInvalid(role_str(role))),
180        }
181    }
182    Ok(())
183}
184
185#[cfg(feature = "eip712")]
186fn role_str(role: &str) -> &'static str {
187    match role {
188        "principal" => "principal",
189        "agent" => "agent",
190        _ => "principal",
191    }
192}
193
194fn sv(msg: &str) -> VerifyError {
195    VerifyError::SchemaViolation(msg.to_string())
196}