Skip to main content

crue_engine/
proof.rs

1//! Strict proof binding primitives (Phase 2 bootstrap).
2
3use crate::context::{EvaluationContext, FieldValue};
4use crate::decision::Decision;
5use crate::EvaluationRequest;
6use crue_dsl::compiler::Bytecode;
7use serde::Serialize;
8
9const PROOF_BINDING_SERIALIZATION_VERSION: u8 = 1;
10const PROOF_BINDING_SCHEMA_ID: &str = "rsrp.proof.binding.v1";
11const PROOF_ENVELOPE_SERIALIZATION_VERSION: u8 = 1;
12const PROOF_ENVELOPE_SCHEMA_ID: &str = "rsrp.proof.envelope.v1";
13pub const PROOF_ENVELOPE_V1_VERSION: u8 = 1;
14pub const PROOF_ENVELOPE_V1_ENCODING_VERSION: u8 = 1;
15#[cfg(feature = "pq-proof")]
16const PQ_PROOF_ENVELOPE_SERIALIZATION_VERSION: u8 = 1;
17#[cfg(feature = "pq-proof")]
18const PQ_PROOF_ENVELOPE_SCHEMA_ID: &str = "rsrp.proof.envelope.pq-hybrid.v1";
19
20#[derive(Debug, Clone, Serialize, serde::Deserialize)]
21pub struct ProofBinding {
22    pub serialization_version: u8,
23    pub schema_id: String,
24    pub runtime_version: String,
25    pub crypto_backend_id: String,
26    pub policy_hash: String,
27    pub bytecode_hash: String,
28    pub input_hash: String,
29    pub state_hash: String,
30    pub decision: Decision,
31}
32
33#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq, Eq)]
34pub struct ProofEnvelope {
35    pub serialization_version: u8,
36    pub schema_id: String,
37    pub signature_algorithm: String,
38    pub signer_key_id: String,
39    pub binding: ProofBinding,
40    pub signature: Vec<u8>,
41}
42
43#[cfg(feature = "pq-proof")]
44#[derive(Clone, Serialize, serde::Deserialize)]
45pub struct PqProofEnvelope {
46    pub serialization_version: u8,
47    pub schema_id: String,
48    pub signature_algorithm: String,
49    pub signer_key_id: String,
50    pub pq_backend_id: String,
51    pub level: pqcrypto::DilithiumLevel,
52    pub binding: ProofBinding,
53    pub signature: pqcrypto::hybrid::HybridSignature,
54}
55
56/// Decision code for canonical proof envelope v1.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
58#[repr(u8)]
59pub enum DecisionCodeV1 {
60    Allow = 1,
61    Block = 2,
62    Warn = 3,
63    ApprovalRequired = 4,
64}
65
66/// Signature algorithm code for canonical proof envelope v1.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
68#[repr(u8)]
69pub enum SignatureAlgorithmCodeV1 {
70    Ed25519 = 1,
71    #[cfg(feature = "pq-proof")]
72    HybridEd25519Mldsa = 2,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
76pub struct Ed25519SignatureV1 {
77    pub key_id_hash: [u8; 32],
78    pub signature: Vec<u8>,
79}
80
81#[cfg(feature = "pq-proof")]
82#[derive(Clone, Serialize, serde::Deserialize)]
83pub struct HybridSignatureV1 {
84    pub key_id_hash: [u8; 32],
85    pub backend_id_hash: [u8; 32],
86    pub level_code: u8,
87    pub signature: pqcrypto::hybrid::HybridSignature,
88}
89
90#[derive(Clone, Serialize, serde::Deserialize)]
91pub enum SignatureV1 {
92    Ed25519(Ed25519SignatureV1),
93    #[cfg(feature = "pq-proof")]
94    Hybrid(HybridSignatureV1),
95}
96
97/// Canonical proof envelope v1: fixed header + typed signature payload.
98#[derive(Clone, Serialize, serde::Deserialize)]
99pub struct ProofEnvelopeV1 {
100    pub version: u8,
101    pub encoding_version: u8,
102    pub runtime_version: u16,
103    pub policy_hash: [u8; 32],
104    pub bytecode_hash: [u8; 32],
105    pub input_hash: [u8; 32],
106    pub state_hash: [u8; 32],
107    pub decision_code: u8,
108    pub signature: SignatureV1,
109}
110
111impl ProofBinding {
112    pub fn create(
113        bytecode: &Bytecode,
114        request: &EvaluationRequest,
115        ctx: &EvaluationContext,
116        decision: Decision,
117        crypto_backend_id: &str,
118    ) -> Result<Self, String> {
119        Self::create_with_policy_hash(
120            bytecode,
121            request,
122            ctx,
123            decision,
124            crypto_backend_id,
125            None,
126        )
127    }
128
129    pub fn create_with_policy_hash(
130        bytecode: &Bytecode,
131        request: &EvaluationRequest,
132        ctx: &EvaluationContext,
133        decision: Decision,
134        crypto_backend_id: &str,
135        policy_hash_hex: Option<&str>,
136    ) -> Result<Self, String> {
137        let bytecode_hash = sha256_hex(&canonical_json_bytes(bytecode)?);
138        let policy_hash = policy_hash_hex.unwrap_or(&bytecode_hash).to_string();
139        Ok(Self {
140            serialization_version: PROOF_BINDING_SERIALIZATION_VERSION,
141            schema_id: PROOF_BINDING_SCHEMA_ID.to_string(),
142            runtime_version: env!("CARGO_PKG_VERSION").to_string(),
143            crypto_backend_id: crypto_backend_id.to_string(),
144            policy_hash,
145            bytecode_hash,
146            input_hash: sha256_hex(&canonical_json_bytes(request)?),
147            state_hash: sha256_hex(&canonical_json_bytes(&state_snapshot(ctx))?),
148            decision,
149        })
150    }
151
152    pub fn verify_recompute(
153        &self,
154        bytecode: &Bytecode,
155        request: &EvaluationRequest,
156        ctx: &EvaluationContext,
157        decision: Decision,
158        crypto_backend_id: &str,
159    ) -> Result<bool, String> {
160        let recomputed = Self::create_with_policy_hash(
161            bytecode,
162            request,
163            ctx,
164            decision,
165            crypto_backend_id,
166            Some(&self.policy_hash),
167        )?;
168        Ok(self.serialization_version == PROOF_BINDING_SERIALIZATION_VERSION
169            && self.schema_id == PROOF_BINDING_SCHEMA_ID
170            && self == &recomputed)
171    }
172
173    pub fn canonical_bytes(&self) -> Result<Vec<u8>, String> {
174        let json = canonical_json_bytes(self)?;
175        let schema_len: u16 = self
176            .schema_id
177            .len()
178            .try_into()
179            .map_err(|_| "schema_id too long".to_string())?;
180        let payload_len: u32 = json
181            .len()
182            .try_into()
183            .map_err(|_| "payload too long".to_string())?;
184        let mut out = Vec::with_capacity(1 + 2 + self.schema_id.len() + 4 + json.len());
185        out.push(self.serialization_version);
186        out.extend_from_slice(&schema_len.to_be_bytes());
187        out.extend_from_slice(self.schema_id.as_bytes());
188        out.extend_from_slice(&payload_len.to_be_bytes());
189        out.extend_from_slice(&json);
190        Ok(out)
191    }
192}
193
194impl PartialEq for ProofBinding {
195    fn eq(&self, other: &Self) -> bool {
196        self.serialization_version == other.serialization_version
197            && self.schema_id == other.schema_id
198            && self.runtime_version == other.runtime_version
199            && self.crypto_backend_id == other.crypto_backend_id
200            && self.policy_hash == other.policy_hash
201            && self.bytecode_hash == other.bytecode_hash
202            && self.input_hash == other.input_hash
203            && self.state_hash == other.state_hash
204            && self.decision == other.decision
205    }
206}
207
208impl Eq for ProofBinding {}
209
210impl ProofEnvelope {
211    pub fn sign_ed25519(
212        binding: ProofBinding,
213        signer_key_id: impl Into<String>,
214        key_pair: &crypto_core::signature::Ed25519KeyPair,
215    ) -> Result<Self, String> {
216        let payload = binding.canonical_bytes()?;
217        let signature = key_pair.sign(&payload);
218        Ok(Self {
219            serialization_version: PROOF_ENVELOPE_SERIALIZATION_VERSION,
220            schema_id: PROOF_ENVELOPE_SCHEMA_ID.to_string(),
221            signature_algorithm: "ED25519".to_string(),
222            signer_key_id: signer_key_id.into(),
223            binding,
224            signature,
225        })
226    }
227
228    pub fn verify_ed25519(&self, public_key: &[u8]) -> Result<bool, String> {
229        if self.serialization_version != PROOF_ENVELOPE_SERIALIZATION_VERSION
230            || self.schema_id != PROOF_ENVELOPE_SCHEMA_ID
231            || self.signature_algorithm != "ED25519"
232        {
233            return Ok(false);
234        }
235        let payload = self.binding.canonical_bytes()?;
236        crypto_core::signature::verify(
237            &payload,
238            &self.signature,
239            public_key,
240            crypto_core::SignatureAlgorithm::Ed25519,
241        )
242        .map_err(|e| e.to_string())
243    }
244}
245
246#[cfg(feature = "pq-proof")]
247impl PqProofEnvelope {
248    pub fn sign_hybrid(
249        binding: ProofBinding,
250        signer_key_id: impl Into<String>,
251        signer: &pqcrypto::hybrid::HybridSigner,
252        keypair: &pqcrypto::hybrid::HybridKeyPair,
253    ) -> Result<Self, String> {
254        let payload = binding.canonical_bytes()?;
255        let signature = signer
256            .sign(keypair, &payload)
257            .map_err(|e| e.to_string())?;
258
259        Ok(Self {
260            serialization_version: PQ_PROOF_ENVELOPE_SERIALIZATION_VERSION,
261            schema_id: PQ_PROOF_ENVELOPE_SCHEMA_ID.to_string(),
262            signature_algorithm: "HYBRID-ED25519+ML-DSA".to_string(),
263            signer_key_id: signer_key_id.into(),
264            pq_backend_id: signer.backend_id().to_string(),
265            level: keypair.level,
266            binding,
267            signature,
268        })
269    }
270
271    pub fn verify_hybrid(
272        &self,
273        public_key: &pqcrypto::hybrid::HybridPublicKey,
274    ) -> Result<bool, String> {
275        if self.serialization_version != PQ_PROOF_ENVELOPE_SERIALIZATION_VERSION
276            || self.schema_id != PQ_PROOF_ENVELOPE_SCHEMA_ID
277            || self.signature_algorithm != "HYBRID-ED25519+ML-DSA"
278            || self.level != public_key.level
279            || self.signature.quantum.level != self.level
280        {
281            return Ok(false);
282        }
283
284        let payload = self.binding.canonical_bytes()?;
285        let verifier = pqcrypto::hybrid::HybridVerifier::new(self.level);
286        verifier
287            .verify_public(public_key, &payload, &self.signature)
288            .map_err(|e| e.to_string())
289    }
290}
291
292impl ProofEnvelopeV1 {
293    pub fn sign_ed25519(
294        binding: &ProofBinding,
295        signer_key_id: impl AsRef<str>,
296        key_pair: &crypto_core::signature::Ed25519KeyPair,
297    ) -> Result<Self, String> {
298        let mut envelope = Self::unsigned_from_binding(binding, SignatureV1::Ed25519(Ed25519SignatureV1 {
299            key_id_hash: sha256_fixed(signer_key_id.as_ref().as_bytes()),
300            signature: Vec::new(),
301        }))?;
302        let payload = envelope.signing_bytes()?;
303        match &mut envelope.signature {
304            SignatureV1::Ed25519(sig) => sig.signature = key_pair.sign(&payload),
305            #[cfg(feature = "pq-proof")]
306            SignatureV1::Hybrid(_) => return Err("invalid signature variant for ed25519 signing".to_string()),
307        }
308        Ok(envelope)
309    }
310
311    #[cfg(feature = "pq-proof")]
312    pub fn sign_hybrid(
313        binding: &ProofBinding,
314        signer_key_id: impl AsRef<str>,
315        signer: &pqcrypto::hybrid::HybridSigner,
316        keypair: &pqcrypto::hybrid::HybridKeyPair,
317    ) -> Result<Self, String> {
318        let mut envelope = Self::unsigned_from_binding(
319            binding,
320            SignatureV1::Hybrid(HybridSignatureV1 {
321                key_id_hash: sha256_fixed(signer_key_id.as_ref().as_bytes()),
322                backend_id_hash: sha256_fixed(signer.backend_id().as_bytes()),
323                level_code: dilithium_level_code(keypair.level),
324                signature: pqcrypto::hybrid::HybridSignature::new(
325                    Vec::new(),
326                    pqcrypto::signature::DilithiumSignature {
327                        level: keypair.level,
328                        signature: Vec::new(),
329                    },
330                ),
331            }),
332        )?;
333        let payload = envelope.signing_bytes()?;
334        let sig = signer.sign(keypair, &payload).map_err(|e| e.to_string())?;
335        if let SignatureV1::Hybrid(h) = &mut envelope.signature {
336            h.signature = sig;
337        }
338        Ok(envelope)
339    }
340
341    pub fn verify_ed25519(&self, public_key: &[u8]) -> Result<bool, String> {
342        let sig = match &self.signature {
343            SignatureV1::Ed25519(sig) => sig,
344            #[cfg(feature = "pq-proof")]
345            SignatureV1::Hybrid(_) => return Ok(false),
346        };
347        let payload = self.signing_bytes()?;
348        crypto_core::signature::verify(
349            &payload,
350            &sig.signature,
351            public_key,
352            crypto_core::SignatureAlgorithm::Ed25519,
353        )
354        .map_err(|e| e.to_string())
355    }
356
357    #[cfg(feature = "pq-proof")]
358    pub fn verify_hybrid(
359        &self,
360        public_key: &pqcrypto::hybrid::HybridPublicKey,
361    ) -> Result<bool, String> {
362        let SignatureV1::Hybrid(sig) = &self.signature else {
363            return Ok(false);
364        };
365        if sig.level_code != dilithium_level_code(public_key.level) {
366            return Ok(false);
367        }
368        if sig.backend_id_hash == [0u8; 32] {
369            return Ok(false);
370        }
371        let payload = self.signing_bytes()?;
372        let verifier = pqcrypto::hybrid::HybridVerifier::new(public_key.level);
373        verifier
374            .verify_public(public_key, &payload, &sig.signature)
375            .map_err(|e| e.to_string())
376    }
377
378    /// Canonical bytes excluding signature material (signing payload).
379    pub fn signing_bytes(&self) -> Result<Vec<u8>, String> {
380        let mut out = Vec::with_capacity(1 + 1 + 2 + (32 * 4) + 1);
381        out.push(self.version);
382        out.push(self.encoding_version);
383        out.extend_from_slice(&self.runtime_version.to_be_bytes());
384        out.extend_from_slice(&self.policy_hash);
385        out.extend_from_slice(&self.bytecode_hash);
386        out.extend_from_slice(&self.input_hash);
387        out.extend_from_slice(&self.state_hash);
388        out.push(self.decision_code);
389        let sig_meta = self.signature.meta_bytes()?;
390        let sig_meta_len: u16 = sig_meta
391            .len()
392            .try_into()
393            .map_err(|_| "signature metadata too large".to_string())?;
394        out.extend_from_slice(&sig_meta_len.to_be_bytes());
395        out.extend_from_slice(&sig_meta);
396        Ok(out)
397    }
398
399    /// Canonical bytes including signature bytes (stable serialization for ledger embedding/export).
400    pub fn canonical_bytes(&self) -> Result<Vec<u8>, String> {
401        let mut out = self.signing_bytes()?;
402        let sig_bytes = self.signature.signature_owned_bytes();
403        let sig_len: u32 = sig_bytes
404            .len()
405            .try_into()
406            .map_err(|_| "signature too large".to_string())?;
407        out.extend_from_slice(&sig_len.to_be_bytes());
408        out.extend_from_slice(&sig_bytes);
409        Ok(out)
410    }
411
412    pub fn decision(&self) -> Result<Decision, String> {
413        decision_from_code(self.decision_code)
414    }
415
416    fn unsigned_from_binding(binding: &ProofBinding, signature: SignatureV1) -> Result<Self, String> {
417        Ok(Self {
418            version: PROOF_ENVELOPE_V1_VERSION,
419            encoding_version: PROOF_ENVELOPE_V1_ENCODING_VERSION,
420            runtime_version: pack_runtime_version_u16(&binding.runtime_version)?,
421            policy_hash: hex32(&binding.policy_hash)?,
422            bytecode_hash: hex32(&binding.bytecode_hash)?,
423            input_hash: hex32(&binding.input_hash)?,
424            state_hash: hex32(&binding.state_hash)?,
425            decision_code: decision_to_code(binding.decision) as u8,
426            signature,
427        })
428    }
429}
430
431impl SignatureV1 {
432    fn meta_bytes(&self) -> Result<Vec<u8>, String> {
433        match self {
434            SignatureV1::Ed25519(sig) => {
435                let mut out = Vec::with_capacity(1 + 32);
436                out.push(SignatureAlgorithmCodeV1::Ed25519 as u8);
437                out.extend_from_slice(&sig.key_id_hash);
438                Ok(out)
439            }
440            #[cfg(feature = "pq-proof")]
441            SignatureV1::Hybrid(sig) => {
442                let mut out = Vec::with_capacity(1 + 32 + 32 + 1);
443                out.push(SignatureAlgorithmCodeV1::HybridEd25519Mldsa as u8);
444                out.extend_from_slice(&sig.key_id_hash);
445                out.extend_from_slice(&sig.backend_id_hash);
446                out.push(sig.level_code);
447                Ok(out)
448            }
449        }
450    }
451
452    fn signature_owned_bytes(&self) -> Vec<u8> {
453        match self {
454            SignatureV1::Ed25519(sig) => sig.signature.clone(),
455            #[cfg(feature = "pq-proof")]
456            SignatureV1::Hybrid(sig) => sig.signature.to_bytes(),
457        }
458    }
459}
460
461#[derive(Serialize)]
462struct StateField<'a> {
463    key: &'a str,
464    value: &'a FieldValue,
465}
466
467fn state_snapshot(ctx: &EvaluationContext) -> Vec<StateField<'_>> {
468    let mut items: Vec<_> = ctx.fields().iter().collect();
469    items.sort_by(|(ka, _), (kb, _)| ka.cmp(kb));
470    items.into_iter()
471        .map(|(key, value)| StateField {
472            key: key.as_str(),
473            value,
474        })
475        .collect()
476}
477
478fn canonical_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, String> {
479    serde_json::to_vec(value).map_err(|e| e.to_string())
480}
481
482fn sha256_hex(data: &[u8]) -> String {
483    use crypto_core::hash::{hex_encode, sha256};
484    hex_encode(&sha256(data))
485}
486
487fn sha256_fixed(data: &[u8]) -> [u8; 32] {
488    let digest = crypto_core::hash::sha256(data);
489    let mut out = [0u8; 32];
490    out.copy_from_slice(&digest[..32]);
491    out
492}
493
494fn hex32(hex: &str) -> Result<[u8; 32], String> {
495    let decoded = crypto_core::hash::hex_decode(hex).map_err(|e| e.to_string())?;
496    if decoded.len() != 32 {
497        return Err(format!("expected 32-byte hash, got {}", decoded.len()));
498    }
499    let mut out = [0u8; 32];
500    out.copy_from_slice(&decoded);
501    Ok(out)
502}
503
504fn pack_runtime_version_u16(runtime_version: &str) -> Result<u16, String> {
505    // Packs semver major.minor.patch as (major << 8) | minor.
506    let mut parts = runtime_version.split('.');
507    let major: u16 = parts
508        .next()
509        .ok_or_else(|| "missing major runtime version".to_string())?
510        .parse()
511        .map_err(|_| "invalid major runtime version".to_string())?;
512    let minor: u16 = parts
513        .next()
514        .ok_or_else(|| "missing minor runtime version".to_string())?
515        .parse()
516        .map_err(|_| "invalid minor runtime version".to_string())?;
517    if major > 0xFF || minor > 0xFF {
518        return Err("runtime_version major/minor exceed u8 packing".to_string());
519    }
520    Ok((major << 8) | minor)
521}
522
523fn decision_to_code(decision: Decision) -> DecisionCodeV1 {
524    match decision {
525        Decision::Allow => DecisionCodeV1::Allow,
526        Decision::Block => DecisionCodeV1::Block,
527        Decision::Warn => DecisionCodeV1::Warn,
528        Decision::ApprovalRequired => DecisionCodeV1::ApprovalRequired,
529    }
530}
531
532fn decision_from_code(code: u8) -> Result<Decision, String> {
533    match code {
534        x if x == DecisionCodeV1::Allow as u8 => Ok(Decision::Allow),
535        x if x == DecisionCodeV1::Block as u8 => Ok(Decision::Block),
536        x if x == DecisionCodeV1::Warn as u8 => Ok(Decision::Warn),
537        x if x == DecisionCodeV1::ApprovalRequired as u8 => Ok(Decision::ApprovalRequired),
538        _ => Err(format!("invalid decision code {}", code)),
539    }
540}
541
542#[cfg(feature = "pq-proof")]
543fn dilithium_level_code(level: pqcrypto::DilithiumLevel) -> u8 {
544    match level {
545        pqcrypto::DilithiumLevel::Dilithium2 => 2,
546        pqcrypto::DilithiumLevel::Dilithium3 => 3,
547        pqcrypto::DilithiumLevel::Dilithium5 => 5,
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use crate::context::EvaluationContext;
555
556    fn fixed_hash_hex(byte: u8) -> String {
557        crypto_core::hash::hex_encode(&[byte; 32])
558    }
559
560    #[test]
561    fn test_proof_binding_recompute_detects_bytecode_change() {
562        let src = r#"
563RULE CRUE_001 VERSION 1.0
564WHEN
565    agent.requests_last_hour >= 50
566THEN
567    BLOCK WITH CODE "VOLUME_EXCEEDED"
568"#;
569        let ast = crue_dsl::parser::parse(src).unwrap();
570        let mut bytecode = crue_dsl::compiler::Compiler::compile(&ast).unwrap();
571
572        let req = crate::EvaluationRequest {
573            request_id: "r".into(),
574            agent_id: "a".into(),
575            agent_org: "o".into(),
576            agent_level: "l".into(),
577            mission_id: None,
578            mission_type: None,
579            query_type: None,
580            justification: None,
581            export_format: None,
582            result_limit: None,
583            requests_last_hour: 60,
584            requests_last_24h: 10,
585            results_last_query: 1,
586            account_department: None,
587            allowed_departments: vec![],
588            request_hour: 9,
589            is_within_mission_hours: true,
590        };
591        let ctx = EvaluationContext::from_request(&req);
592        let proof = ProofBinding::create(&bytecode, &req, &ctx, Decision::Block, "mock-crypto").unwrap();
593        assert!(proof
594            .verify_recompute(&bytecode, &req, &ctx, Decision::Block, "mock-crypto")
595            .unwrap());
596
597        bytecode.instructions.push(0x00);
598        assert!(!proof
599            .verify_recompute(&bytecode, &req, &ctx, Decision::Block, "mock-crypto")
600            .unwrap());
601    }
602
603    #[test]
604    fn test_proof_envelope_ed25519_sign_verify() {
605        let src = r#"
606RULE CRUE_002 VERSION 1.0
607WHEN
608    agent.requests_last_hour >= 10
609THEN
610    BLOCK WITH CODE "TEST"
611"#;
612        let ast = crue_dsl::parser::parse(src).unwrap();
613        let bytecode = crue_dsl::compiler::Compiler::compile(&ast).unwrap();
614        let req = crate::EvaluationRequest {
615            request_id: "r".into(),
616            agent_id: "a".into(),
617            agent_org: "o".into(),
618            agent_level: "l".into(),
619            mission_id: None,
620            mission_type: None,
621            query_type: None,
622            justification: None,
623            export_format: None,
624            result_limit: None,
625            requests_last_hour: 12,
626            requests_last_24h: 10,
627            results_last_query: 1,
628            account_department: None,
629            allowed_departments: vec![],
630            request_hour: 9,
631            is_within_mission_hours: true,
632        };
633        let ctx = EvaluationContext::from_request(&req);
634        let binding =
635            ProofBinding::create(&bytecode, &req, &ctx, Decision::Block, "mock-crypto").unwrap();
636        let kp = crypto_core::signature::Ed25519KeyPair::generate().unwrap();
637        let envelope = ProofEnvelope::sign_ed25519(binding, "proof-key-1", &kp).unwrap();
638        let pk = kp.verifying_key();
639        assert!(envelope.verify_ed25519(&pk).unwrap());
640    }
641
642    #[test]
643    fn test_proof_envelope_v1_ed25519_vector_fixture() {
644        let binding = ProofBinding {
645            serialization_version: 1,
646            schema_id: "rsrp.proof.binding.v1".to_string(),
647            runtime_version: "0.9.1".to_string(),
648            crypto_backend_id: "mock-crypto".to_string(),
649            policy_hash: fixed_hash_hex(0x11),
650            bytecode_hash: fixed_hash_hex(0x22),
651            input_hash: fixed_hash_hex(0x33),
652            state_hash: fixed_hash_hex(0x44),
653            decision: Decision::Block,
654        };
655        let kp = crypto_core::signature::Ed25519KeyPair::derive_from_secret(
656            b"rsrp-proof-envelope-v1-ed25519-test-vector",
657            Some("fixture-ed25519-key".into()),
658        );
659        let pk = kp.verifying_key();
660        let env = ProofEnvelopeV1::sign_ed25519(&binding, "fixture-ed25519-key", &kp).unwrap();
661        assert_eq!(env.decision().unwrap(), Decision::Block);
662        assert!(env.verify_ed25519(&pk).unwrap());
663
664        let signing_hex = crypto_core::hash::hex_encode(&env.signing_bytes().unwrap());
665        let canonical_hex = crypto_core::hash::hex_encode(&env.canonical_bytes().unwrap());
666
667        assert_eq!(
668            signing_hex,
669            "01010009111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222222222223333333333333333333333333333333333333333333333333333333333333333444444444444444444444444444444444444444444444444444444444444444402002101e7e331964026891ae93f6f0d4b20c19f95cf20d6c6ba87fd73e287b081a46201"
670        );
671        assert_eq!(
672            canonical_hex,
673            "01010009111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222222222223333333333333333333333333333333333333333333333333333333333333333444444444444444444444444444444444444444444444444444444444444444402002101e7e331964026891ae93f6f0d4b20c19f95cf20d6c6ba87fd73e287b081a4620100000040ec3e14a8311ebc1d76c65054b7b011cbf9b10d6796417b9e69bc3cb28fd6aab41228c26d034d52b6690680ea27617a35db24993cd24dd296c3905b1338272d05"
674        );
675    }
676
677    #[test]
678    fn test_pack_runtime_version_u16_ignores_patch() {
679        assert_eq!(pack_runtime_version_u16("0.9.4").unwrap(), pack_runtime_version_u16("0.9.99").unwrap());
680        assert_eq!(pack_runtime_version_u16("1.2.3").unwrap(), 0x0102);
681    }
682
683    #[cfg(feature = "pq-proof")]
684    #[test]
685    fn test_pq_proof_envelope_hybrid_sign_verify() {
686        let src = r#"
687RULE CRUE_003 VERSION 1.0
688WHEN
689    agent.requests_last_hour >= 10
690THEN
691    BLOCK WITH CODE "TEST"
692"#;
693        let ast = crue_dsl::parser::parse(src).unwrap();
694        let bytecode = crue_dsl::compiler::Compiler::compile(&ast).unwrap();
695        let req = crate::EvaluationRequest {
696            request_id: "r".into(),
697            agent_id: "a".into(),
698            agent_org: "o".into(),
699            agent_level: "l".into(),
700            mission_id: None,
701            mission_type: None,
702            query_type: None,
703            justification: None,
704            export_format: None,
705            result_limit: None,
706            requests_last_hour: 12,
707            requests_last_24h: 10,
708            results_last_query: 1,
709            account_department: None,
710            allowed_departments: vec![],
711            request_hour: 9,
712            is_within_mission_hours: true,
713        };
714        let ctx = EvaluationContext::from_request(&req);
715        let binding =
716            ProofBinding::create(&bytecode, &req, &ctx, Decision::Block, "mock-crypto").unwrap();
717
718        let signer = pqcrypto::hybrid::HybridSigner::new(pqcrypto::DilithiumLevel::Dilithium2);
719        let kp = signer.generate_keypair().unwrap();
720        let pk = kp.public_key();
721        let envelope = PqProofEnvelope::sign_hybrid(binding, "pq-proof-key-1", &signer, &kp).unwrap();
722        assert_eq!(envelope.pq_backend_id, signer.backend_id());
723        assert!(envelope.verify_hybrid(&pk).unwrap());
724    }
725}