inherence-verifier 0.1.0

Reference verifier for Inherence receipts (verification protocol v1).
Documentation
//! Payload schema + cross-block consistency checks.

use serde_json::Value;

use crate::error::VerifyError;
use crate::VerifyConfig;

const ACCEPTED_SCHEMA_VERSIONS: &[&str] = &["inherence/1.6", "inherence/2.0"];
const REQUIRED_TOP: &[&str] = &[
    "iss", "sub", "aud", "iat", "exp", "jti", "schema_version", "verification", "claims",
];
const REQUIRED_VERIFICATION: &[&str] = &[
    "trust_framework", "verification_process", "policy", "evidence", "attestor",
];
const REQUIRED_CLAIMS: &[&str] = &[
    "service_id", "operation_type", "input_hash", "output_hash",
];
const CONTRACT_STATES: &[&str] = &[
    "Quoted", "Accepted", "InProgress", "Delivered", "Verified", "Disputed", "Terminated",
];

pub fn validate_shape(p: &Value) -> Result<(), VerifyError> {
    let obj = p.as_object().ok_or_else(|| sv("payload is not an object"))?;
    for k in REQUIRED_TOP {
        if !obj.contains_key(*k) {
            return Err(sv(&format!("missing top-level {k:?}")));
        }
    }
    let sv_str = obj["schema_version"].as_str().ok_or_else(|| sv("schema_version not a string"))?;
    if !ACCEPTED_SCHEMA_VERSIONS.contains(&sv_str) {
        return Err(sv(&format!("schema_version {sv_str:?} not in accepted set")));
    }
    let verif = obj["verification"].as_object().ok_or_else(|| sv("verification not an object"))?;
    for k in REQUIRED_VERIFICATION {
        if !verif.contains_key(*k) {
            return Err(sv(&format!("verification missing {k:?}")));
        }
    }
    let evidence = verif["evidence"].as_array().ok_or_else(|| sv("evidence not an array"))?;
    if evidence.len() < 2 {
        return Err(sv("evidence must have at least 2 entries"));
    }
    let has_pe = evidence.iter().any(|e| e.get("type").and_then(|v| v.as_str()) == Some("policy_evaluation"));
    let has_cp = evidence.iter().any(|e| e.get("type").and_then(|v| v.as_str()) == Some("cryptographic_proof"));
    if !has_pe { return Err(sv("evidence missing policy_evaluation")); }
    if !has_cp { return Err(sv("evidence missing cryptographic_proof")); }

    let claims = obj["claims"].as_object().ok_or_else(|| sv("claims not an object"))?;
    for k in REQUIRED_CLAIMS {
        if !claims.contains_key(*k) {
            return Err(sv(&format!("claims missing {k:?}")));
        }
    }

    if is_v2(p) {
        let contract = verif.get("contract").and_then(|v| v.as_object())
            .ok_or_else(|| sv("v2 receipt missing verification.contract"))?;
        for k in &["contract_id", "contract_hash", "state", "principal_signatures", "predicate_witness"] {
            if !contract.contains_key(*k) {
                return Err(sv(&format!("verification.contract missing {k:?}")));
            }
        }
        let state = contract["state"].as_str().unwrap_or("");
        if !CONTRACT_STATES.contains(&state) {
            return Err(sv(&format!("contract.state {state:?} not canonical")));
        }
        let witness = contract["predicate_witness"].as_object()
            .ok_or_else(|| sv("predicate_witness not an object"))?;
        for (k, v) in witness {
            if !v.is_boolean() {
                return Err(sv(&format!("predicate_witness[{k:?}] not boolean")));
            }
        }

        let pi = verif.get("public_inputs").and_then(|v| v.as_object())
            .ok_or_else(|| sv("v2 receipt missing verification.public_inputs"))?;
        for k in &["action_hash", "contract_hash", "decision_bit", "vk_hash"] {
            if !pi.contains_key(*k) {
                return Err(sv(&format!("public_inputs missing {k:?}")));
            }
        }
        let db = pi["decision_bit"].as_i64().unwrap_or(-1);
        if db != 0 && db != 1 {
            return Err(sv(&format!("decision_bit must be 0 or 1, got {db}")));
        }
    }

    Ok(())
}

pub fn check_temporal(p: &Value, cfg: &VerifyConfig) -> Result<(), VerifyError> {
    let iat = p["iat"].as_u64().ok_or_else(|| sv("iat not unsigned int"))?;
    let exp = p["exp"].as_u64().ok_or_else(|| sv("exp not unsigned int"))?;
    let now = cfg.now();
    let skew = cfg.skew();
    if now + skew < iat {
        return Err(VerifyError::NotYetValid);
    }
    if exp + skew < now {
        return Err(VerifyError::Expired);
    }
    Ok(())
}

pub fn check_issuer_audience(p: &Value) -> Result<(), VerifyError> {
    let iss = p["iss"].as_str().unwrap_or("");
    if iss != "https://inherence.dev/v1" {
        return Err(VerifyError::WrongIssuer(iss.to_string()));
    }
    let aud = p["aud"].as_str().unwrap_or("");
    if aud != "audit" {
        return Err(VerifyError::WrongAudience(aud.to_string()));
    }
    Ok(())
}

pub fn is_v2(p: &Value) -> bool {
    p.get("schema_version").and_then(|v| v.as_str())
        .map(|s| s.starts_with("inherence/2."))
        .unwrap_or(false)
}

pub fn check_cross_block_contract_hash(p: &Value) -> Result<(), VerifyError> {
    let cb = &p["verification"]["contract"]["contract_hash"];
    let pi = &p["verification"]["public_inputs"]["contract_hash"];
    if cb != pi {
        return Err(VerifyError::ContractHashMismatch);
    }
    Ok(())
}

pub fn check_decision_bit_consistency(p: &Value) -> Result<(), VerifyError> {
    let decision = p["claims"]["agent_decision"].as_str().unwrap_or("");
    let bit = p["verification"]["public_inputs"]["decision_bit"].as_i64().unwrap_or(-1);
    let expected = match decision { "allow" => 1, "deny" => 0, _ => -1 };
    if bit != expected {
        return Err(VerifyError::DecisionBitMismatch);
    }
    Ok(())
}

pub fn check_state_machine(p: &Value) -> Result<(), VerifyError> {
    let state = p["verification"]["contract"]["state"].as_str().unwrap_or("");
    let decision = p["claims"]["agent_decision"].as_str().unwrap_or("");
    // Strict-mode check (SPEC ยง6.2, V19): in revoked-scope states,
    // no decision should be allow.
    if (state == "Terminated" || state == "Disputed") && decision == "allow" {
        return Err(VerifyError::StateInvalid(
            format!("decision=allow not permitted in state {state:?}")));
    }
    Ok(())
}

pub fn check_vk_pin(p: &Value, cfg: &VerifyConfig) -> Result<(), VerifyError> {
    let vk = p["verification"]["public_inputs"]["vk_hash"].as_str().unwrap_or("");
    if !cfg.knows_vk(vk) {
        return Err(VerifyError::UnknownVk(vk.to_string()));
    }
    Ok(())
}

#[cfg(feature = "eip712")]
pub fn check_principal_signatures(p: &Value) -> Result<(), VerifyError> {
    use crate::eip712;
    let contract = &p["verification"]["contract"];
    let contract_hash_hex = contract["contract_hash"].as_str().unwrap_or("");
    let sigs = &contract["principal_signatures"];
    for &role in &["principal", "agent"] {
        let did = sigs[role]["did"].as_str().unwrap_or("");
        let sig_hex = sigs[role]["signature"].as_str().unwrap_or("");
        match eip712::recover_address(contract_hash_hex, sig_hex) {
            Ok(recovered) => {
                let expected = eip712::address_from_did_ethr(did);
                if expected.eq_ignore_ascii_case(&recovered) {
                    continue;
                }
                return Err(VerifyError::PrincipalSignatureInvalid(role_str(role)));
            }
            Err(_) => return Err(VerifyError::PrincipalSignatureInvalid(role_str(role))),
        }
    }
    Ok(())
}

#[cfg(feature = "eip712")]
fn role_str(role: &str) -> &'static str {
    match role {
        "principal" => "principal",
        "agent" => "agent",
        _ => "principal",
    }
}

fn sv(msg: &str) -> VerifyError {
    VerifyError::SchemaViolation(msg.to_string())
}