use crate::merkle::Hash;
use crate::zk::{ZkError, ZkVerifier};
use serde::{Deserialize, Serialize};
use sha2::Digest;
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
#[serde(untagged, rename_all = "snake_case")]
pub enum IntentData {
Transparent {
request_sha256: String,
confidence: f64,
#[serde(default)]
capabilities: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
magpie_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
continuation_token: Option<ContinuationToken>,
#[serde(flatten, default)]
#[schema(ignore)]
metadata: SchemaValue,
},
Shadow {
commitment_hash: String,
stark_proof_b64: String,
#[schema(ignore)]
public_inputs: SchemaValue,
#[serde(skip_serializing_if = "Option::is_none")]
circuit_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
continuation_token: Option<ContinuationToken>,
#[serde(flatten, default)]
#[schema(ignore)]
metadata: SchemaValue,
},
}
impl IntentData {
pub fn continuation_token(&self) -> Option<&ContinuationToken> {
match self {
IntentData::Transparent {
continuation_token, ..
} => continuation_token.as_ref(),
IntentData::Shadow {
continuation_token, ..
} => continuation_token.as_ref(),
}
}
pub fn circuit_id(&self) -> Option<String> {
match self {
IntentData::Transparent { .. } => None,
IntentData::Shadow { circuit_id, .. } => circuit_id.clone(),
}
}
pub fn metadata(&self) -> &SchemaValue {
match self {
IntentData::Transparent { metadata, .. } => metadata,
IntentData::Shadow { metadata, .. } => metadata,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(transparent)]
pub struct SchemaValue(pub serde_json::Value);
impl std::ops::Deref for SchemaValue {
type Target = serde_json::Value;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for SchemaValue {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Default for SchemaValue {
fn default() -> Self {
Self(serde_json::Value::Null)
}
}
impl SchemaValue {
pub fn is_null(&self) -> bool {
self.0.is_null()
}
}
impl utoipa::PartialSchema for SchemaValue {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::RefOr::T(utoipa::openapi::ObjectBuilder::new().into())
}
}
impl utoipa::ToSchema for SchemaValue {
fn name() -> std::borrow::Cow<'static, str> {
"SchemaValue".into()
}
}
impl IntentData {
pub fn to_jcs_hash(&self) -> Result<Hash, String> {
let jcs_bytes =
serde_jcs::to_vec(self).map_err(|e| format!("JCS serialization failed: {}", e))?;
Ok(Hash::digest(&jcs_bytes))
}
pub fn verify_shadow(&self, verifier: &dyn ZkVerifier) -> Result<bool, ZkError> {
match self {
IntentData::Transparent { .. } => Ok(true),
IntentData::Shadow {
commitment_hash,
stark_proof_b64,
public_inputs,
..
} => verifier.verify_stark(commitment_hash, stark_proof_b64, &public_inputs.0),
}
}
pub fn shadow_public_inputs(&self) -> Result<ShadowPublicInputs, ZkError> {
match self {
IntentData::Shadow { public_inputs, .. } => {
ShadowPublicInputs::from_schema_value(public_inputs).map_err(ZkError::ConfigError)
}
_ => Err(ZkError::ConfigError("Not a Shadow intent".to_string())),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
pub struct ShadowPublicInputs {
pub schema: String,
pub start_root: String,
pub commitment_hash: String,
pub circuit_id: String,
pub public_salt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub verifier_params_hash: Option<String>,
}
impl ShadowPublicInputs {
pub const SCHEMA_V1: &'static str = "vex.shadow_intent.public_inputs.v1";
pub fn validate(&self) -> Result<(), String> {
if self.schema != Self::SCHEMA_V1 {
return Err("unsupported schema".to_string());
}
if self.start_root.len() != 64 || !self.start_root.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("start_root must be 32-byte hex".to_string());
}
if self.commitment_hash.len() != 64
|| !self.commitment_hash.chars().all(|c| c.is_ascii_hexdigit())
{
return Err("commitment_hash must be 32-byte hex".to_string());
}
if self.circuit_id.trim().is_empty() {
return Err("circuit_id must not be empty".to_string());
}
Ok(())
}
pub fn to_schema_value(&self) -> SchemaValue {
SchemaValue(serde_json::to_value(self).expect("serializes"))
}
pub fn from_schema_value(v: &SchemaValue) -> Result<Self, String> {
let decoded: Self = serde_json::from_value(v.0.clone()).map_err(|e| e.to_string())?;
decoded.validate()?;
Ok(decoded)
}
pub fn verify_opening(&self, prompt: &str) -> bool {
let salt = hex::decode(&self.public_salt).unwrap_or_default();
let expected = semantic_commitment_hash(prompt, &salt);
self.commitment_hash == expected
}
}
pub const SHADOW_INTENT_DOMAIN_TAG: &[u8] = b"vex.shadow_intent.commitment.v1";
pub const FULL_ROUNDS_START: usize = 4;
pub const PARTIAL_ROUNDS: usize = 22;
pub const TOTAL_ROUNDS: usize = 30;
pub const WIDTH: usize = 8;
pub const FULL_WIDTH: usize = 28;
pub const GOLDILOCKS_PRIME: u64 = 0xFFFF_FFFF_0000_0001;
pub const COL_STATE_START: usize = 0;
pub const COL_AUX_START: usize = 8;
pub const COL_CONST_START: usize = 16;
pub const COL_IS_FULL: usize = 24;
pub const COL_IS_ACTIVE: usize = 25;
pub const COL_IS_LAST: usize = 26;
pub const COL_IS_REAL: usize = 27;
pub const ME_CIRC: [u64; 8] = [3, 1, 1, 1, 1, 1, 1, 2];
pub const MU: [u64; 4] = [5, 6, 5, 6];
pub fn get_round_constant(round: usize, element: usize) -> u64 {
let base = (round + 1) as u64 * 0x12345678;
let offset = (element + 1) as u64 * 0x87654321;
base.wrapping_add(offset) % GOLDILOCKS_PRIME
}
pub fn structural_permute(state: &mut [u64; 8]) {
use p3_field::PrimeField64;
use p3_goldilocks::Goldilocks;
let mut g_state = [Goldilocks::default(); 8];
for i in 0..8 {
g_state[i] = Goldilocks::new(state[i] % GOLDILOCKS_PRIME);
}
for step in 0..TOTAL_ROUNDS {
let is_full = !(FULL_ROUNDS_START..FULL_ROUNDS_START + PARTIAL_ROUNDS).contains(&step);
let mut sbox_out = [Goldilocks::default(); 8];
for i in 0..8 {
let x = g_state[i];
sbox_out[i] = if is_full || i == 0 {
let x2 = x * x;
let x4 = x2 * x2;
x4 * x2 * x
} else {
x
};
}
let mut next_state = [Goldilocks::default(); 8];
if is_full {
for r in 0..8 {
let mut sum = Goldilocks::default();
for c in 0..8 {
sum += Goldilocks::new(ME_CIRC[(8 + r - c) % 8]) * sbox_out[c];
}
next_state[r] = sum + Goldilocks::new(get_round_constant(step, r));
}
} else {
let mut sum_sbox = Goldilocks::default();
for x in sbox_out.iter() {
sum_sbox += *x;
}
for i in 0..8 {
let mu_m1 = MU[i % 4] - 1;
next_state[i] = Goldilocks::new(mu_m1) * sbox_out[i]
+ sum_sbox
+ Goldilocks::new(get_round_constant(step, i));
}
}
g_state = next_state;
}
for i in 0..8 {
state[i] = PrimeField64::as_canonical_u64(&g_state[i]);
}
}
pub fn canonical_encode_shadow_intent(prompt: &str, salt: &[u8]) -> Vec<u8> {
let mut bytes = Vec::with_capacity(
SHADOW_INTENT_DOMAIN_TAG.len() + std::mem::size_of::<u64>() * 2 + prompt.len() + salt.len(),
);
bytes.extend_from_slice(SHADOW_INTENT_DOMAIN_TAG);
bytes.extend_from_slice(&(prompt.len() as u64).to_le_bytes());
bytes.extend_from_slice(prompt.as_bytes());
bytes.extend_from_slice(&(salt.len() as u64).to_le_bytes());
bytes.extend_from_slice(salt);
bytes
}
pub fn semantic_commitment_hash(prompt: &str, salt: &[u8]) -> String {
let preimage = sha2::Sha256::digest(canonical_encode_shadow_intent(prompt, salt));
let initial_val = u64::from_le_bytes(preimage[0..8].try_into().unwrap());
let mut state = [0u64; 8];
state[0] = initial_val;
structural_permute(&mut state);
let mut hash_bytes = Vec::with_capacity(32);
for lane in state.iter().take(4) {
hash_bytes.extend_from_slice(&lane.to_le_bytes());
}
hex::encode(hash_bytes)
}
pub fn hash_to_m31_chunks(hash: &[u8; 32]) -> [u32; 9] {
let mut chunks = [0u32; 9];
for (i, item) in chunks.iter_mut().enumerate() {
let bit_offset = i * 31;
let mut val = 0u64;
for b in 0..31 {
let total_bit = bit_offset + b;
if total_bit >= 256 {
break;
}
let byte_idx = total_bit / 8;
let bit_in_byte = total_bit % 8;
if (hash[byte_idx] & (1 << bit_in_byte)) != 0 {
val |= 1 << b;
}
}
*item = (val & 0x7FFFFFFF) as u32;
}
chunks
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
pub struct AuthorityData {
pub capsule_id: String,
pub outcome: String,
pub reason_code: String,
pub trace_root: String,
pub nonce: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub escalation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub binding_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub continuation_token: Option<ContinuationToken>,
#[serde(skip_serializing_if = "Option::is_none")]
pub authority_class: Option<String>,
#[serde(
default = "default_sensor_value",
skip_serializing_if = "SchemaValue::is_null"
)]
pub gate_sensors: SchemaValue,
#[serde(flatten, default)]
pub metadata: SchemaValue,
}
impl AuthorityData {
pub const CLASS_STRUCTURAL: &'static str = "STRUCTURAL_TERMINAL";
pub const CLASS_POLICY: &'static str = "POLICY_TERMINAL";
pub const CLASS_AMBIGUITY: &'static str = "ESCALATABLE_AMBIGUITY";
pub const CLASS_ALLOW: &'static str = "ALLOW_PATH";
}
fn default_sensor_value() -> SchemaValue {
SchemaValue(serde_json::Value::Null)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
pub struct WitnessData {
pub chora_node_id: String,
pub receipt_hash: String,
pub timestamp: u64,
#[serde(flatten, default)]
pub metadata: SchemaValue,
}
impl WitnessData {
pub fn to_commitment_hash(&self) -> Result<Hash, String> {
#[derive(Serialize)]
struct MinimalWitness<'a> {
chora_node_id: &'a str,
timestamp: u64,
}
let minimal = MinimalWitness {
chora_node_id: &self.chora_node_id,
timestamp: self.timestamp,
};
let jcs_bytes = serde_jcs::to_vec(&minimal)
.map_err(|e| format!("JCS serialization of minimal witness failed: {}", e))?;
Ok(Hash::digest(&jcs_bytes))
}
pub fn to_jcs_hash(&self) -> Result<Hash, String> {
self.to_commitment_hash()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
pub struct IdentityData {
pub aid: String,
pub identity_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pcrs: Option<std::collections::HashMap<u32, String>>,
#[serde(flatten, default)]
pub metadata: SchemaValue,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
pub struct ContinuationToken {
pub payload: ContinuationPayload,
pub signature: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
pub struct ExecutionTarget {
pub aid: String,
pub circuit_id: String,
pub intent_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
pub struct ContinuationPayload {
pub schema: String,
pub ledger_event_id: String,
pub source_capsule_root: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution_event_id: Option<String>,
#[serde(default)]
pub capabilities: Vec<String>,
pub nonce: String,
pub execution_target: ExecutionTarget,
pub iat: i64,
pub exp: i64,
pub issuer: String,
}
impl ContinuationPayload {
pub fn validate_lifecycle(&self, now: chrono::DateTime<chrono::Utc>) -> Result<(), String> {
let now_unix = now.timestamp();
let leeway = 30;
if now_unix < self.iat - leeway {
return Err("Token issued in the future (beyond leeway)".to_string());
}
if now_unix > self.exp + leeway {
return Err("Token expired (beyond leeway)".to_string());
}
Ok(())
}
}
impl ContinuationToken {
pub fn payload_hash(&self) -> Result<Vec<u8>, String> {
let jcs_bytes = serde_jcs::to_vec(&self.payload)
.map_err(|e| format!("JCS serialization failed: {}", e))?;
let mut hasher = sha2::Sha256::new();
hasher.update(&jcs_bytes);
Ok(hasher.finalize().to_vec())
}
pub fn verify_v3(&self, public_key_hex: &str) -> Result<bool, String> {
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let pub_key_bytes =
hex::decode(public_key_hex).map_err(|e| format!("Invalid public key hex: {}", e))?;
let public_key = VerifyingKey::from_bytes(
pub_key_bytes
.as_slice()
.try_into()
.map_err(|_| "Invalid Key Length")?,
)
.map_err(|e| format!("Invalid Ed25519 public key: {}", e))?;
let sig_bytes =
hex::decode(&self.signature).map_err(|e| format!("Invalid signature hex: {}", e))?;
let sig_array: [u8; 64] = sig_bytes
.as_slice()
.try_into()
.map_err(|_| "Signature must be 64 bytes".to_string())?;
let signature = Signature::from_bytes(&sig_array);
let hash = self.payload_hash()?;
Ok(public_key.verify(&hash, &signature).is_ok())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
pub struct CryptoData {
pub algo: String,
pub public_key_endpoint: String,
pub signature_scope: String,
pub signature_b64: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
pub struct RequestCommitment {
pub canonicalization: String,
pub payload_sha256: String,
pub payload_encoding: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
pub struct Capsule {
pub capsule_id: String,
pub intent: IntentData,
pub authority: AuthorityData,
pub identity: IdentityData,
pub witness: WitnessData,
pub intent_hash: String,
pub authority_hash: String,
pub identity_hash: String,
pub witness_hash: String,
pub capsule_root: String,
pub crypto: CryptoData,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_commitment: Option<RequestCommitment>,
}
impl Capsule {
pub fn to_composite_hash(&self) -> Result<Hash, String> {
let intent_h = self.intent.to_jcs_hash()?;
let authority_h = {
let jcs = serde_jcs::to_vec(&self.authority).map_err(|e| e.to_string())?;
Hash::digest(&jcs)
};
let identity_h = {
let jcs = serde_jcs::to_vec(&self.identity).map_err(|e| e.to_string())?;
Hash::digest(&jcs)
};
let witness_h = self.witness.to_jcs_hash()?;
let leaves = vec![
("intent".to_string(), intent_h),
("authority".to_string(), authority_h),
("identity".to_string(), identity_h),
("witness".to_string(), witness_h),
];
let tree = crate::merkle::MerkleTree::from_leaves(leaves);
tree.root_hash()
.cloned()
.ok_or_else(|| "Failed to calculate Merkle root".to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
pub struct VexReceipt {
pub schema: String,
pub capsule: Capsule,
pub proving_metadata: ProvingMetadata,
}
impl VexReceipt {
pub const SCHEMA_V1: &'static str = "vex.receipt.v1";
pub fn new(capsule: Capsule, proving_metadata: ProvingMetadata) -> Self {
Self {
schema: Self::SCHEMA_V1.to_string(),
capsule,
proving_metadata,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
pub struct ProvingMetadata {
pub circuit_id: String,
pub proving_engine: String,
pub machine_id: String,
pub proving_duration_ms: u64,
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
pub struct StreamFinalProof {
pub stream_id: String,
pub receipts: Vec<VexReceipt>,
pub aggregate_proof_b64: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_intent_segment_jcs_deterministic() {
let segment1 = IntentData::Transparent {
request_sha256: "8ee6010d905547c377c67e63559e989b8073b168f11a1ffefd092c7ca962076e"
.to_string(),
confidence: 0.95,
capabilities: vec![],
magpie_source: None,
continuation_token: None,
metadata: SchemaValue::default(),
};
let segment2 = segment1.clone();
let hash1 = segment1.to_jcs_hash().unwrap();
let hash2 = segment2.to_jcs_hash().unwrap();
assert_eq!(hash1, hash2, "JCS hashing must be deterministic");
}
#[test]
fn test_intent_segment_content_change() {
let segment1 = IntentData::Transparent {
request_sha256: "a".into(),
confidence: 0.5,
capabilities: vec![],
magpie_source: None,
continuation_token: None,
metadata: SchemaValue::default(),
};
let mut segment2 = segment1.clone();
if let IntentData::Transparent {
ref mut confidence, ..
} = segment2
{
*confidence = 0.9;
}
let hash1 = segment1.to_jcs_hash().unwrap();
let hash2 = segment2.to_jcs_hash().unwrap();
assert_ne!(hash1, hash2, "Hashes must change when content changes");
}
#[test]
fn test_shadow_intent_jcs_deterministic() {
let segment1 = IntentData::Shadow {
commitment_hash: "5555555555555555555555555555555555555555555555555555555555555555"
.to_string(),
stark_proof_b64: "c29tZS1zdGFyay1wcm9vZg==".to_string(),
public_inputs: SchemaValue(serde_json::json!({
"policy_id": "standard-v1",
"outcome_commitment": "ALLOW"
})),
circuit_id: None,
continuation_token: None,
metadata: SchemaValue::default(),
};
let segment2 = segment1.clone();
let hash1 = segment1.to_jcs_hash().unwrap();
let hash2 = segment2.to_jcs_hash().unwrap();
assert_eq!(hash1, hash2, "Shadow JCS hashing must be deterministic");
let jcs_bytes = serde_jcs::to_vec(&segment1).unwrap();
let jcs_str = String::from_utf8(jcs_bytes).unwrap();
assert!(
jcs_str.contains("\"commitment_hash\""),
"JCS must include the commitment_hash"
);
}
#[test]
fn test_witness_metadata_exclusion() {
let base_witness = WitnessData {
chora_node_id: "node-1".to_string(),
receipt_hash: "hash-1".to_string(),
timestamp: 1710396000,
metadata: SchemaValue(serde_json::Value::Null),
};
let hash_base = base_witness.to_commitment_hash().unwrap();
let mut metadata_witness = base_witness.clone();
metadata_witness.metadata = SchemaValue(serde_json::json!({
"witness_mode": "sentinel",
"diagnostics": {
"latency_ms": 42
}
}));
let hash_with_metadata = metadata_witness.to_commitment_hash().unwrap();
assert_eq!(
hash_base, hash_with_metadata,
"Witness hash must be invariant to extra metadata fields"
);
}
#[test]
fn test_witness_segment_minimal_interop() {
let witness = WitnessData {
chora_node_id: "chora-gate-v1".to_string(),
receipt_hash: "ignored-in-v03".to_string(),
timestamp: 1710396000,
metadata: SchemaValue(serde_json::json!({
"witness_mode": "full",
"observational_only": false
})),
};
let hash_hex = witness.to_commitment_hash().expect("Hashing failed");
assert_eq!(
hash_hex.to_hex(),
"87657d67389ca1a0e3e9bd4bccb5ab60a1cdcc59902d4cd67826d285dd98bff5",
"v0.3 witness hash must include 0x00 leaf prefix and exclude receipt_hash"
);
}
#[test]
fn test_authority_extra_fields_parity() {
let json_data = serde_json::json!({
"capsule_id": "example-capsule-001",
"outcome": "ALLOW",
"reason_code": "policy_ok",
"trace_root": "trace-001",
"nonce": "1234567890",
"gate_sensors": null,
"rule_set_owner": "chora-authority-node",
"fail_closed": true
});
let authority: AuthorityData = serde_json::from_value(json_data.clone()).unwrap();
assert_eq!(authority.metadata["rule_set_owner"], "chora-authority-node");
assert_eq!(authority.metadata["fail_closed"], true);
let jcs_bytes = serde_jcs::to_vec(&authority).unwrap();
let jcs_str = String::from_utf8(jcs_bytes).unwrap();
assert!(jcs_str.contains("\"rule_set_owner\":\"chora-authority-node\""));
}
}