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::{ActionDecision, ActionInstruction, Bytecode, Constant};
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: u32,
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(bytecode, request, ctx, decision, crypto_backend_id, None)
120    }
121
122    pub fn create_with_policy_hash(
123        bytecode: &Bytecode,
124        request: &EvaluationRequest,
125        ctx: &EvaluationContext,
126        decision: Decision,
127        crypto_backend_id: &str,
128        policy_hash_hex: Option<&str>,
129    ) -> Result<Self, String> {
130        let bytecode_hash = sha256_hex(&canonical_bytecode_bytes(bytecode)?);
131        let policy_hash = policy_hash_hex.unwrap_or(&bytecode_hash).to_string();
132        Ok(Self {
133            serialization_version: PROOF_BINDING_SERIALIZATION_VERSION,
134            schema_id: PROOF_BINDING_SCHEMA_ID.to_string(),
135            runtime_version: env!("CARGO_PKG_VERSION").to_string(),
136            crypto_backend_id: crypto_backend_id.to_string(),
137            policy_hash,
138            bytecode_hash,
139            input_hash: sha256_hex(&canonical_request_bytes(request)?),
140            state_hash: sha256_hex(&canonical_state_bytes(&state_snapshot(ctx))?),
141            decision,
142        })
143    }
144
145    pub fn verify_recompute(
146        &self,
147        bytecode: &Bytecode,
148        request: &EvaluationRequest,
149        ctx: &EvaluationContext,
150        decision: Decision,
151        crypto_backend_id: &str,
152    ) -> Result<bool, String> {
153        let recomputed = Self::create_with_policy_hash(
154            bytecode,
155            request,
156            ctx,
157            decision,
158            crypto_backend_id,
159            Some(&self.policy_hash),
160        )?;
161        Ok(
162            self.serialization_version == PROOF_BINDING_SERIALIZATION_VERSION
163                && self.schema_id == PROOF_BINDING_SCHEMA_ID
164                && self == &recomputed,
165        )
166    }
167
168    pub fn canonical_bytes(&self) -> Result<Vec<u8>, String> {
169        let payload = self.canonical_payload_bytes()?;
170        let schema_len: u16 = self
171            .schema_id
172            .len()
173            .try_into()
174            .map_err(|_| "schema_id too long".to_string())?;
175        let payload_len: u32 = payload
176            .len()
177            .try_into()
178            .map_err(|_| "payload too long".to_string())?;
179        let mut out = Vec::with_capacity(1 + 2 + self.schema_id.len() + 4 + payload.len());
180        out.push(self.serialization_version);
181        out.extend_from_slice(&schema_len.to_be_bytes());
182        out.extend_from_slice(self.schema_id.as_bytes());
183        out.extend_from_slice(&payload_len.to_be_bytes());
184        out.extend_from_slice(&payload);
185        Ok(out)
186    }
187
188    fn canonical_payload_bytes(&self) -> Result<Vec<u8>, String> {
189        let mut out = Vec::with_capacity(
190            1 + 2 + self.runtime_version.len() + 2 + self.crypto_backend_id.len() + (32 * 4) + 1,
191        );
192        encode_len_prefixed_str(&mut out, &self.runtime_version)?;
193        encode_len_prefixed_str(&mut out, &self.crypto_backend_id)?;
194        out.extend_from_slice(&hex32(&self.policy_hash)?);
195        out.extend_from_slice(&hex32(&self.bytecode_hash)?);
196        out.extend_from_slice(&hex32(&self.input_hash)?);
197        out.extend_from_slice(&hex32(&self.state_hash)?);
198        out.push(decision_to_code(self.decision) as u8);
199        Ok(out)
200    }
201}
202
203impl PartialEq for ProofBinding {
204    fn eq(&self, other: &Self) -> bool {
205        self.serialization_version == other.serialization_version
206            && self.schema_id == other.schema_id
207            && self.runtime_version == other.runtime_version
208            && self.crypto_backend_id == other.crypto_backend_id
209            && self.policy_hash == other.policy_hash
210            && self.bytecode_hash == other.bytecode_hash
211            && self.input_hash == other.input_hash
212            && self.state_hash == other.state_hash
213            && self.decision == other.decision
214    }
215}
216
217impl Eq for ProofBinding {}
218
219impl ProofEnvelope {
220    pub fn sign_ed25519(
221        binding: ProofBinding,
222        signer_key_id: impl Into<String>,
223        key_pair: &crypto_core::signature::Ed25519KeyPair,
224    ) -> Result<Self, String> {
225        let payload = binding.canonical_bytes()?;
226        let signature = key_pair.sign(&payload);
227        Ok(Self {
228            serialization_version: PROOF_ENVELOPE_SERIALIZATION_VERSION,
229            schema_id: PROOF_ENVELOPE_SCHEMA_ID.to_string(),
230            signature_algorithm: "ED25519".to_string(),
231            signer_key_id: signer_key_id.into(),
232            binding,
233            signature,
234        })
235    }
236
237    pub fn verify_ed25519(&self, public_key: &[u8]) -> Result<bool, String> {
238        if self.serialization_version != PROOF_ENVELOPE_SERIALIZATION_VERSION
239            || self.schema_id != PROOF_ENVELOPE_SCHEMA_ID
240            || self.signature_algorithm != "ED25519"
241        {
242            return Ok(false);
243        }
244        let payload = self.binding.canonical_bytes()?;
245        crypto_core::signature::verify(
246            &payload,
247            &self.signature,
248            public_key,
249            crypto_core::SignatureAlgorithm::Ed25519,
250        )
251        .map_err(|e| e.to_string())
252    }
253}
254
255#[cfg(feature = "pq-proof")]
256impl PqProofEnvelope {
257    pub fn sign_hybrid(
258        binding: ProofBinding,
259        signer_key_id: impl Into<String>,
260        signer: &pqcrypto::hybrid::HybridSigner,
261        keypair: &pqcrypto::hybrid::HybridKeyPair,
262    ) -> Result<Self, String> {
263        let payload = binding.canonical_bytes()?;
264        let signature = signer.sign(keypair, &payload).map_err(|e| e.to_string())?;
265
266        Ok(Self {
267            serialization_version: PQ_PROOF_ENVELOPE_SERIALIZATION_VERSION,
268            schema_id: PQ_PROOF_ENVELOPE_SCHEMA_ID.to_string(),
269            signature_algorithm: "HYBRID-ED25519+ML-DSA".to_string(),
270            signer_key_id: signer_key_id.into(),
271            pq_backend_id: signer.backend_id().to_string(),
272            level: keypair.level,
273            binding,
274            signature,
275        })
276    }
277
278    pub fn verify_hybrid(
279        &self,
280        public_key: &pqcrypto::hybrid::HybridPublicKey,
281    ) -> Result<bool, String> {
282        if self.serialization_version != PQ_PROOF_ENVELOPE_SERIALIZATION_VERSION
283            || self.schema_id != PQ_PROOF_ENVELOPE_SCHEMA_ID
284            || self.signature_algorithm != "HYBRID-ED25519+ML-DSA"
285            || self.level != public_key.level
286            || self.signature.quantum.level != self.level
287        {
288            return Ok(false);
289        }
290
291        let payload = self.binding.canonical_bytes()?;
292        let verifier = pqcrypto::hybrid::HybridVerifier::new(self.level);
293        verifier
294            .verify_public(public_key, &payload, &self.signature)
295            .map_err(|e| e.to_string())
296    }
297}
298
299impl ProofEnvelopeV1 {
300    pub fn sign_ed25519(
301        binding: &ProofBinding,
302        signer_key_id: impl AsRef<str>,
303        key_pair: &crypto_core::signature::Ed25519KeyPair,
304    ) -> Result<Self, String> {
305        let mut envelope = Self::unsigned_from_binding(
306            binding,
307            SignatureV1::Ed25519(Ed25519SignatureV1 {
308                key_id_hash: sha256_fixed(signer_key_id.as_ref().as_bytes()),
309                signature: Vec::new(),
310            }),
311        )?;
312        let payload = envelope.signing_bytes()?;
313        match &mut envelope.signature {
314            SignatureV1::Ed25519(sig) => sig.signature = key_pair.sign(&payload),
315            #[cfg(feature = "pq-proof")]
316            SignatureV1::Hybrid(_) => {
317                return Err("invalid signature variant for ed25519 signing".to_string())
318            }
319        }
320        Ok(envelope)
321    }
322
323    #[cfg(feature = "pq-proof")]
324    pub fn sign_hybrid(
325        binding: &ProofBinding,
326        signer_key_id: impl AsRef<str>,
327        signer: &pqcrypto::hybrid::HybridSigner,
328        keypair: &pqcrypto::hybrid::HybridKeyPair,
329    ) -> Result<Self, String> {
330        let mut envelope = Self::unsigned_from_binding(
331            binding,
332            SignatureV1::Hybrid(HybridSignatureV1 {
333                key_id_hash: sha256_fixed(signer_key_id.as_ref().as_bytes()),
334                backend_id_hash: sha256_fixed(signer.backend_id().as_bytes()),
335                level_code: dilithium_level_code(keypair.level),
336                signature: pqcrypto::hybrid::HybridSignature::new(
337                    Vec::new(),
338                    pqcrypto::signature::DilithiumSignature {
339                        level: keypair.level,
340                        signature: Vec::new(),
341                    },
342                ),
343            }),
344        )?;
345        let payload = envelope.signing_bytes()?;
346        let sig = signer.sign(keypair, &payload).map_err(|e| e.to_string())?;
347        if let SignatureV1::Hybrid(h) = &mut envelope.signature {
348            h.signature = sig;
349        }
350        Ok(envelope)
351    }
352
353    pub fn verify_ed25519(&self, public_key: &[u8]) -> Result<bool, String> {
354        let sig = match &self.signature {
355            SignatureV1::Ed25519(sig) => sig,
356            #[cfg(feature = "pq-proof")]
357            SignatureV1::Hybrid(_) => return Ok(false),
358        };
359        let payload = self.signing_bytes()?;
360        crypto_core::signature::verify(
361            &payload,
362            &sig.signature,
363            public_key,
364            crypto_core::SignatureAlgorithm::Ed25519,
365        )
366        .map_err(|e| e.to_string())
367    }
368
369    #[cfg(feature = "pq-proof")]
370    pub fn verify_hybrid(
371        &self,
372        public_key: &pqcrypto::hybrid::HybridPublicKey,
373    ) -> Result<bool, String> {
374        let SignatureV1::Hybrid(sig) = &self.signature else {
375            return Ok(false);
376        };
377        if sig.level_code != dilithium_level_code(public_key.level) {
378            return Ok(false);
379        }
380        if sig.backend_id_hash == [0u8; 32] {
381            return Ok(false);
382        }
383        let payload = self.signing_bytes()?;
384        let verifier = pqcrypto::hybrid::HybridVerifier::new(public_key.level);
385        verifier
386            .verify_public(public_key, &payload, &sig.signature)
387            .map_err(|e| e.to_string())
388    }
389
390    /// Canonical bytes excluding signature material (signing payload).
391    pub fn signing_bytes(&self) -> Result<Vec<u8>, String> {
392        let mut out = Vec::with_capacity(1 + 1 + 4 + (32 * 4) + 1);
393        out.push(self.version);
394        out.push(self.encoding_version);
395        out.extend_from_slice(&self.runtime_version.to_be_bytes());
396        out.extend_from_slice(&self.policy_hash);
397        out.extend_from_slice(&self.bytecode_hash);
398        out.extend_from_slice(&self.input_hash);
399        out.extend_from_slice(&self.state_hash);
400        out.push(self.decision_code);
401        let sig_meta = self.signature.meta_bytes()?;
402        let sig_meta_len: u16 = sig_meta
403            .len()
404            .try_into()
405            .map_err(|_| "signature metadata too large".to_string())?;
406        out.extend_from_slice(&sig_meta_len.to_be_bytes());
407        out.extend_from_slice(&sig_meta);
408        Ok(out)
409    }
410
411    /// Canonical bytes including signature bytes (stable serialization for ledger embedding/export).
412    pub fn canonical_bytes(&self) -> Result<Vec<u8>, String> {
413        let mut out = self.signing_bytes()?;
414        let sig_bytes = self.signature.signature_owned_bytes();
415        let sig_len: u32 = sig_bytes
416            .len()
417            .try_into()
418            .map_err(|_| "signature too large".to_string())?;
419        out.extend_from_slice(&sig_len.to_be_bytes());
420        out.extend_from_slice(&sig_bytes);
421        Ok(out)
422    }
423
424    /// Decode canonical bytes produced by `canonical_bytes`.
425    pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, String> {
426        let mut cursor = 0usize;
427        let version = read_u8(bytes, &mut cursor)?;
428        let encoding_version = read_u8(bytes, &mut cursor)?;
429        let runtime_version = read_u32_be(bytes, &mut cursor)?;
430        let policy_hash = read_fixed_32(bytes, &mut cursor)?;
431        let bytecode_hash = read_fixed_32(bytes, &mut cursor)?;
432        let input_hash = read_fixed_32(bytes, &mut cursor)?;
433        let state_hash = read_fixed_32(bytes, &mut cursor)?;
434        let decision_code = read_u8(bytes, &mut cursor)?;
435
436        let sig_meta_len = read_u16_be(bytes, &mut cursor)? as usize;
437        let sig_meta = read_slice(bytes, &mut cursor, sig_meta_len)?;
438        let mut signature = SignatureV1::from_meta_bytes(sig_meta)?;
439
440        let sig_len = read_u32_be(bytes, &mut cursor)? as usize;
441        let sig_bytes = read_slice(bytes, &mut cursor, sig_len)?;
442        signature.attach_signature_bytes(sig_bytes)?;
443
444        if cursor != bytes.len() {
445            return Err("unexpected trailing bytes in ProofEnvelopeV1".to_string());
446        }
447
448        Ok(Self {
449            version,
450            encoding_version,
451            runtime_version,
452            policy_hash,
453            bytecode_hash,
454            input_hash,
455            state_hash,
456            decision_code,
457            signature,
458        })
459    }
460
461    pub fn decision(&self) -> Result<Decision, String> {
462        decision_from_code(self.decision_code)
463    }
464
465    fn unsigned_from_binding(
466        binding: &ProofBinding,
467        signature: SignatureV1,
468    ) -> Result<Self, String> {
469        Ok(Self {
470            version: PROOF_ENVELOPE_V1_VERSION,
471            encoding_version: PROOF_ENVELOPE_V1_ENCODING_VERSION,
472            runtime_version: pack_runtime_version_u32(&binding.runtime_version)?,
473            policy_hash: hex32(&binding.policy_hash)?,
474            bytecode_hash: hex32(&binding.bytecode_hash)?,
475            input_hash: hex32(&binding.input_hash)?,
476            state_hash: hex32(&binding.state_hash)?,
477            decision_code: decision_to_code(binding.decision) as u8,
478            signature,
479        })
480    }
481}
482
483impl SignatureV1 {
484    fn meta_bytes(&self) -> Result<Vec<u8>, String> {
485        match self {
486            SignatureV1::Ed25519(sig) => {
487                let mut out = Vec::with_capacity(1 + 32);
488                out.push(SignatureAlgorithmCodeV1::Ed25519 as u8);
489                out.extend_from_slice(&sig.key_id_hash);
490                Ok(out)
491            }
492            #[cfg(feature = "pq-proof")]
493            SignatureV1::Hybrid(sig) => {
494                let mut out = Vec::with_capacity(1 + 32 + 32 + 1);
495                out.push(SignatureAlgorithmCodeV1::HybridEd25519Mldsa as u8);
496                out.extend_from_slice(&sig.key_id_hash);
497                out.extend_from_slice(&sig.backend_id_hash);
498                out.push(sig.level_code);
499                Ok(out)
500            }
501        }
502    }
503
504    fn signature_owned_bytes(&self) -> Vec<u8> {
505        match self {
506            SignatureV1::Ed25519(sig) => sig.signature.clone(),
507            #[cfg(feature = "pq-proof")]
508            SignatureV1::Hybrid(sig) => sig.signature.to_bytes(),
509        }
510    }
511
512    fn from_meta_bytes(meta: &[u8]) -> Result<Self, String> {
513        if meta.is_empty() {
514            return Err("missing signature metadata".to_string());
515        }
516        match meta[0] {
517            x if x == SignatureAlgorithmCodeV1::Ed25519 as u8 => {
518                if meta.len() != 1 + 32 {
519                    return Err("invalid Ed25519 signature metadata length".to_string());
520                }
521                let mut key_id_hash = [0u8; 32];
522                key_id_hash.copy_from_slice(&meta[1..33]);
523                Ok(SignatureV1::Ed25519(Ed25519SignatureV1 {
524                    key_id_hash,
525                    signature: Vec::new(),
526                }))
527            }
528            #[cfg(feature = "pq-proof")]
529            x if x == SignatureAlgorithmCodeV1::HybridEd25519Mldsa as u8 => {
530                if meta.len() != 1 + 32 + 32 + 1 {
531                    return Err("invalid Hybrid signature metadata length".to_string());
532                }
533                let mut key_id_hash = [0u8; 32];
534                key_id_hash.copy_from_slice(&meta[1..33]);
535                let mut backend_id_hash = [0u8; 32];
536                backend_id_hash.copy_from_slice(&meta[33..65]);
537                let level_code = meta[65];
538                let level = dilithium_level_from_code(level_code)?;
539                Ok(SignatureV1::Hybrid(HybridSignatureV1 {
540                    key_id_hash,
541                    backend_id_hash,
542                    level_code,
543                    signature: pqcrypto::hybrid::HybridSignature::new(
544                        Vec::new(),
545                        pqcrypto::signature::DilithiumSignature {
546                            level,
547                            signature: Vec::new(),
548                        },
549                    ),
550                }))
551            }
552            _ => Err(format!("unknown signature algorithm code {}", meta[0])),
553        }
554    }
555
556    fn attach_signature_bytes(&mut self, signature_bytes: &[u8]) -> Result<(), String> {
557        match self {
558            SignatureV1::Ed25519(sig) => {
559                if signature_bytes.len() != 64 {
560                    return Err("invalid Ed25519 signature length".to_string());
561                }
562                sig.signature = signature_bytes.to_vec();
563                Ok(())
564            }
565            #[cfg(feature = "pq-proof")]
566            SignatureV1::Hybrid(sig) => {
567                let level = dilithium_level_from_code(sig.level_code)?;
568                sig.signature =
569                    pqcrypto::hybrid::HybridSignature::from_bytes(level, signature_bytes)
570                        .map_err(|e| e.to_string())?;
571                Ok(())
572            }
573        }
574    }
575}
576
577#[derive(Serialize)]
578struct StateField<'a> {
579    key: &'a str,
580    value: &'a FieldValue,
581}
582
583fn state_snapshot(ctx: &EvaluationContext) -> Vec<StateField<'_>> {
584    let mut items: Vec<_> = ctx.fields().iter().collect();
585    items.sort_by(|(ka, _), (kb, _)| ka.cmp(kb));
586    items
587        .into_iter()
588        .map(|(key, value)| StateField {
589            key: key.as_str(),
590            value,
591        })
592        .collect()
593}
594
595fn canonical_bytecode_bytes(bytecode: &Bytecode) -> Result<Vec<u8>, String> {
596    let mut out = Vec::new();
597    out.extend_from_slice(b"rsrp.bytecode.v1");
598
599    encode_len_prefixed_bytes(&mut out, &bytecode.instructions)?;
600
601    encode_u32(
602        &mut out,
603        bytecode
604            .constants
605            .len()
606            .try_into()
607            .map_err(|_| "too many constants".to_string())?,
608    );
609    for c in &bytecode.constants {
610        match c {
611            Constant::Number(n) => {
612                out.push(1);
613                out.extend_from_slice(&n.to_be_bytes());
614            }
615            Constant::String(s) => {
616                out.push(2);
617                encode_len_prefixed_bytes(&mut out, s.as_bytes())?;
618            }
619            Constant::Boolean(b) => {
620                out.push(3);
621                out.push(u8::from(*b));
622            }
623        }
624    }
625
626    encode_u32(
627        &mut out,
628        bytecode
629            .fields
630            .len()
631            .try_into()
632            .map_err(|_| "too many fields".to_string())?,
633    );
634    for field in &bytecode.fields {
635        encode_len_prefixed_bytes(&mut out, field.as_bytes())?;
636    }
637
638    encode_u32(
639        &mut out,
640        bytecode
641            .action_instructions
642            .len()
643            .try_into()
644            .map_err(|_| "too many action instructions".to_string())?,
645    );
646    for instruction in &bytecode.action_instructions {
647        encode_action_instruction(&mut out, instruction)?;
648    }
649
650    Ok(out)
651}
652
653fn canonical_request_bytes(request: &EvaluationRequest) -> Result<Vec<u8>, String> {
654    let mut out = Vec::new();
655    out.extend_from_slice(b"rsrp.request.v1");
656    encode_len_prefixed_bytes(&mut out, request.request_id.as_bytes())?;
657    encode_len_prefixed_bytes(&mut out, request.agent_id.as_bytes())?;
658    encode_len_prefixed_bytes(&mut out, request.agent_org.as_bytes())?;
659    encode_len_prefixed_bytes(&mut out, request.agent_level.as_bytes())?;
660    encode_opt_str(&mut out, request.mission_id.as_deref())?;
661    encode_opt_str(&mut out, request.mission_type.as_deref())?;
662    encode_opt_str(&mut out, request.query_type.as_deref())?;
663    encode_opt_str(&mut out, request.justification.as_deref())?;
664    encode_opt_str(&mut out, request.export_format.as_deref())?;
665    encode_opt_u32(&mut out, request.result_limit);
666    encode_u32(&mut out, request.requests_last_hour);
667    encode_u32(&mut out, request.requests_last_24h);
668    encode_u32(&mut out, request.results_last_query);
669    encode_opt_str(&mut out, request.account_department.as_deref())?;
670    encode_u32(
671        &mut out,
672        request
673            .allowed_departments
674            .len()
675            .try_into()
676            .map_err(|_| "too many departments".to_string())?,
677    );
678    for dept in &request.allowed_departments {
679        encode_u32(&mut out, *dept);
680    }
681    encode_u32(&mut out, request.request_hour);
682    out.push(u8::from(request.is_within_mission_hours));
683    Ok(out)
684}
685
686fn canonical_state_bytes(fields: &[StateField<'_>]) -> Result<Vec<u8>, String> {
687    let mut out = Vec::new();
688    out.extend_from_slice(b"rsrp.state.v1");
689    encode_u32(
690        &mut out,
691        fields
692            .len()
693            .try_into()
694            .map_err(|_| "too many state fields".to_string())?,
695    );
696    for field in fields {
697        encode_len_prefixed_bytes(&mut out, field.key.as_bytes())?;
698        encode_field_value(&mut out, field.value)?;
699    }
700    Ok(out)
701}
702
703fn encode_action_instruction(
704    out: &mut Vec<u8>,
705    instruction: &ActionInstruction,
706) -> Result<(), String> {
707    match instruction {
708        ActionInstruction::SetDecision(d) => {
709            out.push(1);
710            let code = match d {
711                ActionDecision::Allow => 1,
712                ActionDecision::Block => 2,
713                ActionDecision::Warn => 3,
714                ActionDecision::ApprovalRequired => 4,
715            };
716            out.push(code);
717        }
718        ActionInstruction::SetErrorCode(v) => {
719            out.push(2);
720            encode_len_prefixed_bytes(out, v.as_bytes())?;
721        }
722        ActionInstruction::SetMessage(v) => {
723            out.push(3);
724            encode_len_prefixed_bytes(out, v.as_bytes())?;
725        }
726        ActionInstruction::SetApprovalTimeout(v) => {
727            out.push(4);
728            encode_u32(out, *v);
729        }
730        ActionInstruction::SetAlertSoc(v) => {
731            out.push(5);
732            out.push(u8::from(*v));
733        }
734        ActionInstruction::Halt => out.push(6),
735    }
736    Ok(())
737}
738
739fn encode_field_value(out: &mut Vec<u8>, value: &FieldValue) -> Result<(), String> {
740    match value {
741        FieldValue::Number(n) => {
742            out.push(1);
743            out.extend_from_slice(&n.to_be_bytes());
744        }
745        FieldValue::Float(f) => {
746            out.push(2);
747            out.extend_from_slice(&f.to_bits().to_be_bytes());
748        }
749        FieldValue::String(s) => {
750            out.push(3);
751            encode_len_prefixed_bytes(out, s.as_bytes())?;
752        }
753        FieldValue::Boolean(b) => {
754            out.push(4);
755            out.push(u8::from(*b));
756        }
757    }
758    Ok(())
759}
760
761fn encode_u32(out: &mut Vec<u8>, value: u32) {
762    out.extend_from_slice(&value.to_be_bytes());
763}
764
765fn encode_opt_u32(out: &mut Vec<u8>, value: Option<u32>) {
766    match value {
767        Some(v) => {
768            out.push(1);
769            encode_u32(out, v);
770        }
771        None => out.push(0),
772    }
773}
774
775fn encode_opt_str(out: &mut Vec<u8>, value: Option<&str>) -> Result<(), String> {
776    match value {
777        Some(v) => {
778            out.push(1);
779            encode_len_prefixed_bytes(out, v.as_bytes())?;
780        }
781        None => out.push(0),
782    }
783    Ok(())
784}
785
786fn encode_len_prefixed_bytes(out: &mut Vec<u8>, value: &[u8]) -> Result<(), String> {
787    let len: u32 = value
788        .len()
789        .try_into()
790        .map_err(|_| "field too large".to_string())?;
791    encode_u32(out, len);
792    out.extend_from_slice(value);
793    Ok(())
794}
795
796fn encode_len_prefixed_str(out: &mut Vec<u8>, value: &str) -> Result<(), String> {
797    let len: u16 = value
798        .len()
799        .try_into()
800        .map_err(|_| "string field too long".to_string())?;
801    out.extend_from_slice(&len.to_be_bytes());
802    out.extend_from_slice(value.as_bytes());
803    Ok(())
804}
805
806fn sha256_hex(data: &[u8]) -> String {
807    use crypto_core::hash::{hex_encode, sha256};
808    hex_encode(&sha256(data))
809}
810
811fn sha256_fixed(data: &[u8]) -> [u8; 32] {
812    let digest = crypto_core::hash::sha256(data);
813    let mut out = [0u8; 32];
814    out.copy_from_slice(&digest[..32]);
815    out
816}
817
818fn hex32(hex: &str) -> Result<[u8; 32], String> {
819    let decoded = crypto_core::hash::hex_decode(hex).map_err(|e| e.to_string())?;
820    if decoded.len() != 32 {
821        return Err(format!("expected 32-byte hash, got {}", decoded.len()));
822    }
823    let mut out = [0u8; 32];
824    out.copy_from_slice(&decoded);
825    Ok(out)
826}
827
828fn pack_runtime_version_u32(runtime_version: &str) -> Result<u32, String> {
829    // Packs semver major.minor.patch as (major << 24) | (minor << 16) | patch.
830    let mut parts = runtime_version.split('.');
831    let major: u32 = parts
832        .next()
833        .ok_or_else(|| "missing major runtime version".to_string())?
834        .parse()
835        .map_err(|_| "invalid major runtime version".to_string())?;
836    let minor: u32 = parts
837        .next()
838        .ok_or_else(|| "missing minor runtime version".to_string())?
839        .parse()
840        .map_err(|_| "invalid minor runtime version".to_string())?;
841    let patch: u32 = match parts.next() {
842        Some(p) if !p.is_empty() => p
843            .parse()
844            .map_err(|_| "invalid patch runtime version".to_string())?,
845        _ => 0,
846    };
847    if major > 0xFF || minor > 0xFF || patch > 0xFFFF {
848        return Err("runtime_version component exceeds u32 packing limits".to_string());
849    }
850    Ok((major << 24) | (minor << 16) | patch)
851}
852
853fn decision_to_code(decision: Decision) -> DecisionCodeV1 {
854    match decision {
855        Decision::Allow => DecisionCodeV1::Allow,
856        Decision::Block => DecisionCodeV1::Block,
857        Decision::Warn => DecisionCodeV1::Warn,
858        Decision::ApprovalRequired => DecisionCodeV1::ApprovalRequired,
859    }
860}
861
862fn decision_from_code(code: u8) -> Result<Decision, String> {
863    match code {
864        x if x == DecisionCodeV1::Allow as u8 => Ok(Decision::Allow),
865        x if x == DecisionCodeV1::Block as u8 => Ok(Decision::Block),
866        x if x == DecisionCodeV1::Warn as u8 => Ok(Decision::Warn),
867        x if x == DecisionCodeV1::ApprovalRequired as u8 => Ok(Decision::ApprovalRequired),
868        _ => Err(format!("invalid decision code {}", code)),
869    }
870}
871
872#[cfg(feature = "pq-proof")]
873fn dilithium_level_code(level: pqcrypto::DilithiumLevel) -> u8 {
874    match level {
875        pqcrypto::DilithiumLevel::Dilithium2 => 2,
876        pqcrypto::DilithiumLevel::Dilithium3 => 3,
877        pqcrypto::DilithiumLevel::Dilithium5 => 5,
878    }
879}
880
881#[cfg(feature = "pq-proof")]
882fn dilithium_level_from_code(code: u8) -> Result<pqcrypto::DilithiumLevel, String> {
883    match code {
884        2 => Ok(pqcrypto::DilithiumLevel::Dilithium2),
885        3 => Ok(pqcrypto::DilithiumLevel::Dilithium3),
886        5 => Ok(pqcrypto::DilithiumLevel::Dilithium5),
887        _ => Err(format!("invalid Dilithium level code {}", code)),
888    }
889}
890
891fn read_u8(data: &[u8], cursor: &mut usize) -> Result<u8, String> {
892    if *cursor >= data.len() {
893        return Err("unexpected EOF reading u8".to_string());
894    }
895    let v = data[*cursor];
896    *cursor += 1;
897    Ok(v)
898}
899
900fn read_u16_be(data: &[u8], cursor: &mut usize) -> Result<u16, String> {
901    let slice = read_slice(data, cursor, 2)?;
902    Ok(u16::from_be_bytes([slice[0], slice[1]]))
903}
904
905fn read_u32_be(data: &[u8], cursor: &mut usize) -> Result<u32, String> {
906    let slice = read_slice(data, cursor, 4)?;
907    Ok(u32::from_be_bytes([slice[0], slice[1], slice[2], slice[3]]))
908}
909
910fn read_fixed_32(data: &[u8], cursor: &mut usize) -> Result<[u8; 32], String> {
911    let slice = read_slice(data, cursor, 32)?;
912    let mut out = [0u8; 32];
913    out.copy_from_slice(slice);
914    Ok(out)
915}
916
917fn read_slice<'a>(data: &'a [u8], cursor: &mut usize, len: usize) -> Result<&'a [u8], String> {
918    if data.len().saturating_sub(*cursor) < len {
919        return Err("unexpected EOF reading bytes".to_string());
920    }
921    let start = *cursor;
922    *cursor += len;
923    Ok(&data[start..start + len])
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929    use crate::context::EvaluationContext;
930
931    fn fixed_hash_hex(byte: u8) -> String {
932        crypto_core::hash::hex_encode(&[byte; 32])
933    }
934
935    #[test]
936    fn test_proof_binding_recompute_detects_bytecode_change() {
937        let src = r#"
938RULE CRUE_001 VERSION 1.0
939WHEN
940    agent.requests_last_hour >= 50
941THEN
942    BLOCK WITH CODE "VOLUME_EXCEEDED"
943"#;
944        let ast = crue_dsl::parser::parse(src).unwrap();
945        let mut bytecode = crue_dsl::compiler::Compiler::compile(&ast).unwrap();
946
947        let req = crate::EvaluationRequest {
948            request_id: "r".into(),
949            agent_id: "a".into(),
950            agent_org: "o".into(),
951            agent_level: "l".into(),
952            mission_id: None,
953            mission_type: None,
954            query_type: None,
955            justification: None,
956            export_format: None,
957            result_limit: None,
958            requests_last_hour: 60,
959            requests_last_24h: 10,
960            results_last_query: 1,
961            account_department: None,
962            allowed_departments: vec![],
963            request_hour: 9,
964            is_within_mission_hours: true,
965        };
966        let ctx = EvaluationContext::from_request(&req);
967        let proof =
968            ProofBinding::create(&bytecode, &req, &ctx, Decision::Block, "mock-crypto").unwrap();
969        assert!(proof
970            .verify_recompute(&bytecode, &req, &ctx, Decision::Block, "mock-crypto")
971            .unwrap());
972
973        bytecode.instructions.push(0x00);
974        assert!(!proof
975            .verify_recompute(&bytecode, &req, &ctx, Decision::Block, "mock-crypto")
976            .unwrap());
977    }
978
979    #[test]
980    fn test_proof_envelope_ed25519_sign_verify() {
981        let src = r#"
982RULE CRUE_002 VERSION 1.0
983WHEN
984    agent.requests_last_hour >= 10
985THEN
986    BLOCK WITH CODE "TEST"
987"#;
988        let ast = crue_dsl::parser::parse(src).unwrap();
989        let bytecode = crue_dsl::compiler::Compiler::compile(&ast).unwrap();
990        let req = crate::EvaluationRequest {
991            request_id: "r".into(),
992            agent_id: "a".into(),
993            agent_org: "o".into(),
994            agent_level: "l".into(),
995            mission_id: None,
996            mission_type: None,
997            query_type: None,
998            justification: None,
999            export_format: None,
1000            result_limit: None,
1001            requests_last_hour: 12,
1002            requests_last_24h: 10,
1003            results_last_query: 1,
1004            account_department: None,
1005            allowed_departments: vec![],
1006            request_hour: 9,
1007            is_within_mission_hours: true,
1008        };
1009        let ctx = EvaluationContext::from_request(&req);
1010        let binding =
1011            ProofBinding::create(&bytecode, &req, &ctx, Decision::Block, "mock-crypto").unwrap();
1012        let kp = crypto_core::signature::Ed25519KeyPair::generate().unwrap();
1013        let envelope = ProofEnvelope::sign_ed25519(binding, "proof-key-1", &kp).unwrap();
1014        let pk = kp.verifying_key();
1015        assert!(envelope.verify_ed25519(&pk).unwrap());
1016    }
1017
1018    #[test]
1019    fn test_proof_envelope_v1_ed25519_vector_fixture() {
1020        let binding = ProofBinding {
1021            serialization_version: 1,
1022            schema_id: "rsrp.proof.binding.v1".to_string(),
1023            runtime_version: "0.9.1".to_string(),
1024            crypto_backend_id: "mock-crypto".to_string(),
1025            policy_hash: fixed_hash_hex(0x11),
1026            bytecode_hash: fixed_hash_hex(0x22),
1027            input_hash: fixed_hash_hex(0x33),
1028            state_hash: fixed_hash_hex(0x44),
1029            decision: Decision::Block,
1030        };
1031        let kp = crypto_core::signature::Ed25519KeyPair::derive_from_secret(
1032            b"rsrp-proof-envelope-v1-ed25519-test-vector",
1033            Some("fixture-ed25519-key".into()),
1034        );
1035        let pk = kp.verifying_key();
1036        let env = ProofEnvelopeV1::sign_ed25519(&binding, "fixture-ed25519-key", &kp).unwrap();
1037        assert_eq!(env.decision().unwrap(), Decision::Block);
1038        assert!(env.verify_ed25519(&pk).unwrap());
1039
1040        let signing_hex = crypto_core::hash::hex_encode(&env.signing_bytes().unwrap());
1041        let canonical_hex = crypto_core::hash::hex_encode(&env.canonical_bytes().unwrap());
1042
1043        assert_eq!(
1044            signing_hex,
1045            "010100090001111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222222222223333333333333333333333333333333333333333333333333333333333333333444444444444444444444444444444444444444444444444444444444444444402002101e7e331964026891ae93f6f0d4b20c19f95cf20d6c6ba87fd73e287b081a46201"
1046        );
1047        assert_eq!(
1048            canonical_hex,
1049            "010100090001111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222222222223333333333333333333333333333333333333333333333333333333333333333444444444444444444444444444444444444444444444444444444444444444402002101e7e331964026891ae93f6f0d4b20c19f95cf20d6c6ba87fd73e287b081a46201000000406dfc53cce34237ad8fdd62a3fc35b1221d18d7503971bdf73ec1f37d0cacfe002cc3405dfa2c046b66a68760c29c55a2fb8c130cc3d926a54645c771989dc000"
1050        );
1051    }
1052
1053    #[test]
1054    fn test_proof_envelope_v1_canonical_decode_roundtrip() {
1055        let binding = ProofBinding {
1056            serialization_version: 1,
1057            schema_id: "rsrp.proof.binding.v1".to_string(),
1058            runtime_version: "0.9.3".to_string(),
1059            crypto_backend_id: "mock-crypto".to_string(),
1060            policy_hash: fixed_hash_hex(0xAA),
1061            bytecode_hash: fixed_hash_hex(0xBB),
1062            input_hash: fixed_hash_hex(0xCC),
1063            state_hash: fixed_hash_hex(0xDD),
1064            decision: Decision::Allow,
1065        };
1066        let kp = crypto_core::signature::Ed25519KeyPair::derive_from_secret(
1067            b"rsrp-proof-envelope-v1-roundtrip",
1068            Some("fixture-ed25519-key".into()),
1069        );
1070        let pk = kp.verifying_key();
1071        let env = ProofEnvelopeV1::sign_ed25519(&binding, "fixture-ed25519-key", &kp).unwrap();
1072        let bytes = env.canonical_bytes().unwrap();
1073        let decoded = ProofEnvelopeV1::from_canonical_bytes(&bytes).unwrap();
1074
1075        assert_eq!(decoded.canonical_bytes().unwrap(), bytes);
1076        assert_eq!(decoded.decision().unwrap(), Decision::Allow);
1077        assert!(decoded.verify_ed25519(&pk).unwrap());
1078    }
1079
1080    #[test]
1081    fn test_pack_runtime_version_u32_includes_patch() {
1082        assert_ne!(
1083            pack_runtime_version_u32("0.9.4").unwrap(),
1084            pack_runtime_version_u32("0.9.99").unwrap()
1085        );
1086        assert_eq!(pack_runtime_version_u32("1.2.3").unwrap(), 0x01020003);
1087    }
1088
1089    #[cfg(feature = "pq-proof")]
1090    #[test]
1091    fn test_pq_proof_envelope_hybrid_sign_verify() {
1092        let src = r#"
1093RULE CRUE_003 VERSION 1.0
1094WHEN
1095    agent.requests_last_hour >= 10
1096THEN
1097    BLOCK WITH CODE "TEST"
1098"#;
1099        let ast = crue_dsl::parser::parse(src).unwrap();
1100        let bytecode = crue_dsl::compiler::Compiler::compile(&ast).unwrap();
1101        let req = crate::EvaluationRequest {
1102            request_id: "r".into(),
1103            agent_id: "a".into(),
1104            agent_org: "o".into(),
1105            agent_level: "l".into(),
1106            mission_id: None,
1107            mission_type: None,
1108            query_type: None,
1109            justification: None,
1110            export_format: None,
1111            result_limit: None,
1112            requests_last_hour: 12,
1113            requests_last_24h: 10,
1114            results_last_query: 1,
1115            account_department: None,
1116            allowed_departments: vec![],
1117            request_hour: 9,
1118            is_within_mission_hours: true,
1119        };
1120        let ctx = EvaluationContext::from_request(&req);
1121        let binding =
1122            ProofBinding::create(&bytecode, &req, &ctx, Decision::Block, "mock-crypto").unwrap();
1123
1124        let signer = pqcrypto::hybrid::HybridSigner::new(pqcrypto::DilithiumLevel::Dilithium2);
1125        let kp = signer.generate_keypair().unwrap();
1126        let pk = kp.public_key();
1127        let envelope =
1128            PqProofEnvelope::sign_hybrid(binding, "pq-proof-key-1", &signer, &kp).unwrap();
1129        assert_eq!(envelope.pq_backend_id, signer.backend_id());
1130        assert!(envelope.verify_hybrid(&pk).unwrap());
1131    }
1132}