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: 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_json_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_json_bytes(request)?),
140            state_hash: sha256_hex(&canonical_json_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_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, String> {
596    serde_json::to_vec(value).map_err(|e| e.to_string())
597}
598
599fn encode_len_prefixed_str(out: &mut Vec<u8>, value: &str) -> Result<(), String> {
600    let len: u16 = value
601        .len()
602        .try_into()
603        .map_err(|_| "string field too long".to_string())?;
604    out.extend_from_slice(&len.to_be_bytes());
605    out.extend_from_slice(value.as_bytes());
606    Ok(())
607}
608
609fn sha256_hex(data: &[u8]) -> String {
610    use crypto_core::hash::{hex_encode, sha256};
611    hex_encode(&sha256(data))
612}
613
614fn sha256_fixed(data: &[u8]) -> [u8; 32] {
615    let digest = crypto_core::hash::sha256(data);
616    let mut out = [0u8; 32];
617    out.copy_from_slice(&digest[..32]);
618    out
619}
620
621fn hex32(hex: &str) -> Result<[u8; 32], String> {
622    let decoded = crypto_core::hash::hex_decode(hex).map_err(|e| e.to_string())?;
623    if decoded.len() != 32 {
624        return Err(format!("expected 32-byte hash, got {}", decoded.len()));
625    }
626    let mut out = [0u8; 32];
627    out.copy_from_slice(&decoded);
628    Ok(out)
629}
630
631fn pack_runtime_version_u32(runtime_version: &str) -> Result<u32, String> {
632    // Packs semver major.minor.patch as (major << 24) | (minor << 16) | patch.
633    let mut parts = runtime_version.split('.');
634    let major: u32 = parts
635        .next()
636        .ok_or_else(|| "missing major runtime version".to_string())?
637        .parse()
638        .map_err(|_| "invalid major runtime version".to_string())?;
639    let minor: u32 = parts
640        .next()
641        .ok_or_else(|| "missing minor runtime version".to_string())?
642        .parse()
643        .map_err(|_| "invalid minor runtime version".to_string())?;
644    let patch: u32 = match parts.next() {
645        Some(p) if !p.is_empty() => p
646            .parse()
647            .map_err(|_| "invalid patch runtime version".to_string())?,
648        _ => 0,
649    };
650    if major > 0xFF || minor > 0xFF || patch > 0xFFFF {
651        return Err("runtime_version component exceeds u32 packing limits".to_string());
652    }
653    Ok((major << 24) | (minor << 16) | patch)
654}
655
656fn decision_to_code(decision: Decision) -> DecisionCodeV1 {
657    match decision {
658        Decision::Allow => DecisionCodeV1::Allow,
659        Decision::Block => DecisionCodeV1::Block,
660        Decision::Warn => DecisionCodeV1::Warn,
661        Decision::ApprovalRequired => DecisionCodeV1::ApprovalRequired,
662    }
663}
664
665fn decision_from_code(code: u8) -> Result<Decision, String> {
666    match code {
667        x if x == DecisionCodeV1::Allow as u8 => Ok(Decision::Allow),
668        x if x == DecisionCodeV1::Block as u8 => Ok(Decision::Block),
669        x if x == DecisionCodeV1::Warn as u8 => Ok(Decision::Warn),
670        x if x == DecisionCodeV1::ApprovalRequired as u8 => Ok(Decision::ApprovalRequired),
671        _ => Err(format!("invalid decision code {}", code)),
672    }
673}
674
675#[cfg(feature = "pq-proof")]
676fn dilithium_level_code(level: pqcrypto::DilithiumLevel) -> u8 {
677    match level {
678        pqcrypto::DilithiumLevel::Dilithium2 => 2,
679        pqcrypto::DilithiumLevel::Dilithium3 => 3,
680        pqcrypto::DilithiumLevel::Dilithium5 => 5,
681    }
682}
683
684#[cfg(feature = "pq-proof")]
685fn dilithium_level_from_code(code: u8) -> Result<pqcrypto::DilithiumLevel, String> {
686    match code {
687        2 => Ok(pqcrypto::DilithiumLevel::Dilithium2),
688        3 => Ok(pqcrypto::DilithiumLevel::Dilithium3),
689        5 => Ok(pqcrypto::DilithiumLevel::Dilithium5),
690        _ => Err(format!("invalid Dilithium level code {}", code)),
691    }
692}
693
694fn read_u8(data: &[u8], cursor: &mut usize) -> Result<u8, String> {
695    if *cursor >= data.len() {
696        return Err("unexpected EOF reading u8".to_string());
697    }
698    let v = data[*cursor];
699    *cursor += 1;
700    Ok(v)
701}
702
703fn read_u16_be(data: &[u8], cursor: &mut usize) -> Result<u16, String> {
704    let slice = read_slice(data, cursor, 2)?;
705    Ok(u16::from_be_bytes([slice[0], slice[1]]))
706}
707
708fn read_u32_be(data: &[u8], cursor: &mut usize) -> Result<u32, String> {
709    let slice = read_slice(data, cursor, 4)?;
710    Ok(u32::from_be_bytes([slice[0], slice[1], slice[2], slice[3]]))
711}
712
713fn read_fixed_32(data: &[u8], cursor: &mut usize) -> Result<[u8; 32], String> {
714    let slice = read_slice(data, cursor, 32)?;
715    let mut out = [0u8; 32];
716    out.copy_from_slice(slice);
717    Ok(out)
718}
719
720fn read_slice<'a>(data: &'a [u8], cursor: &mut usize, len: usize) -> Result<&'a [u8], String> {
721    if data.len().saturating_sub(*cursor) < len {
722        return Err("unexpected EOF reading bytes".to_string());
723    }
724    let start = *cursor;
725    *cursor += len;
726    Ok(&data[start..start + len])
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732    use crate::context::EvaluationContext;
733
734    fn fixed_hash_hex(byte: u8) -> String {
735        crypto_core::hash::hex_encode(&[byte; 32])
736    }
737
738    #[test]
739    fn test_proof_binding_recompute_detects_bytecode_change() {
740        let src = r#"
741RULE CRUE_001 VERSION 1.0
742WHEN
743    agent.requests_last_hour >= 50
744THEN
745    BLOCK WITH CODE "VOLUME_EXCEEDED"
746"#;
747        let ast = crue_dsl::parser::parse(src).unwrap();
748        let mut bytecode = crue_dsl::compiler::Compiler::compile(&ast).unwrap();
749
750        let req = crate::EvaluationRequest {
751            request_id: "r".into(),
752            agent_id: "a".into(),
753            agent_org: "o".into(),
754            agent_level: "l".into(),
755            mission_id: None,
756            mission_type: None,
757            query_type: None,
758            justification: None,
759            export_format: None,
760            result_limit: None,
761            requests_last_hour: 60,
762            requests_last_24h: 10,
763            results_last_query: 1,
764            account_department: None,
765            allowed_departments: vec![],
766            request_hour: 9,
767            is_within_mission_hours: true,
768        };
769        let ctx = EvaluationContext::from_request(&req);
770        let proof =
771            ProofBinding::create(&bytecode, &req, &ctx, Decision::Block, "mock-crypto").unwrap();
772        assert!(proof
773            .verify_recompute(&bytecode, &req, &ctx, Decision::Block, "mock-crypto")
774            .unwrap());
775
776        bytecode.instructions.push(0x00);
777        assert!(!proof
778            .verify_recompute(&bytecode, &req, &ctx, Decision::Block, "mock-crypto")
779            .unwrap());
780    }
781
782    #[test]
783    fn test_proof_envelope_ed25519_sign_verify() {
784        let src = r#"
785RULE CRUE_002 VERSION 1.0
786WHEN
787    agent.requests_last_hour >= 10
788THEN
789    BLOCK WITH CODE "TEST"
790"#;
791        let ast = crue_dsl::parser::parse(src).unwrap();
792        let bytecode = crue_dsl::compiler::Compiler::compile(&ast).unwrap();
793        let req = crate::EvaluationRequest {
794            request_id: "r".into(),
795            agent_id: "a".into(),
796            agent_org: "o".into(),
797            agent_level: "l".into(),
798            mission_id: None,
799            mission_type: None,
800            query_type: None,
801            justification: None,
802            export_format: None,
803            result_limit: None,
804            requests_last_hour: 12,
805            requests_last_24h: 10,
806            results_last_query: 1,
807            account_department: None,
808            allowed_departments: vec![],
809            request_hour: 9,
810            is_within_mission_hours: true,
811        };
812        let ctx = EvaluationContext::from_request(&req);
813        let binding =
814            ProofBinding::create(&bytecode, &req, &ctx, Decision::Block, "mock-crypto").unwrap();
815        let kp = crypto_core::signature::Ed25519KeyPair::generate().unwrap();
816        let envelope = ProofEnvelope::sign_ed25519(binding, "proof-key-1", &kp).unwrap();
817        let pk = kp.verifying_key();
818        assert!(envelope.verify_ed25519(&pk).unwrap());
819    }
820
821    #[test]
822    fn test_proof_envelope_v1_ed25519_vector_fixture() {
823        let binding = ProofBinding {
824            serialization_version: 1,
825            schema_id: "rsrp.proof.binding.v1".to_string(),
826            runtime_version: "0.9.1".to_string(),
827            crypto_backend_id: "mock-crypto".to_string(),
828            policy_hash: fixed_hash_hex(0x11),
829            bytecode_hash: fixed_hash_hex(0x22),
830            input_hash: fixed_hash_hex(0x33),
831            state_hash: fixed_hash_hex(0x44),
832            decision: Decision::Block,
833        };
834        let kp = crypto_core::signature::Ed25519KeyPair::derive_from_secret(
835            b"rsrp-proof-envelope-v1-ed25519-test-vector",
836            Some("fixture-ed25519-key".into()),
837        );
838        let pk = kp.verifying_key();
839        let env = ProofEnvelopeV1::sign_ed25519(&binding, "fixture-ed25519-key", &kp).unwrap();
840        assert_eq!(env.decision().unwrap(), Decision::Block);
841        assert!(env.verify_ed25519(&pk).unwrap());
842
843        let signing_hex = crypto_core::hash::hex_encode(&env.signing_bytes().unwrap());
844        let canonical_hex = crypto_core::hash::hex_encode(&env.canonical_bytes().unwrap());
845
846        assert_eq!(
847            signing_hex,
848            "010100090001111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222222222223333333333333333333333333333333333333333333333333333333333333333444444444444444444444444444444444444444444444444444444444444444402002101e7e331964026891ae93f6f0d4b20c19f95cf20d6c6ba87fd73e287b081a46201"
849        );
850        assert_eq!(
851            canonical_hex,
852            "010100090001111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222222222223333333333333333333333333333333333333333333333333333333333333333444444444444444444444444444444444444444444444444444444444444444402002101e7e331964026891ae93f6f0d4b20c19f95cf20d6c6ba87fd73e287b081a4620100000040e22e8f4b3ab834f4db936d865b8ded519e0aac395ca625c154840f37f7f571429e91b91f97652e4d84495d903bce814fde0d84bd6606ce854648bc064d25f106"
853        );
854    }
855
856    #[test]
857    fn test_proof_envelope_v1_canonical_decode_roundtrip() {
858        let binding = ProofBinding {
859            serialization_version: 1,
860            schema_id: "rsrp.proof.binding.v1".to_string(),
861            runtime_version: "0.9.3".to_string(),
862            crypto_backend_id: "mock-crypto".to_string(),
863            policy_hash: fixed_hash_hex(0xAA),
864            bytecode_hash: fixed_hash_hex(0xBB),
865            input_hash: fixed_hash_hex(0xCC),
866            state_hash: fixed_hash_hex(0xDD),
867            decision: Decision::Allow,
868        };
869        let kp = crypto_core::signature::Ed25519KeyPair::derive_from_secret(
870            b"rsrp-proof-envelope-v1-roundtrip",
871            Some("fixture-ed25519-key".into()),
872        );
873        let pk = kp.verifying_key();
874        let env = ProofEnvelopeV1::sign_ed25519(&binding, "fixture-ed25519-key", &kp).unwrap();
875        let bytes = env.canonical_bytes().unwrap();
876        let decoded = ProofEnvelopeV1::from_canonical_bytes(&bytes).unwrap();
877
878        assert_eq!(decoded.canonical_bytes().unwrap(), bytes);
879        assert_eq!(decoded.decision().unwrap(), Decision::Allow);
880        assert!(decoded.verify_ed25519(&pk).unwrap());
881    }
882
883    #[test]
884    fn test_pack_runtime_version_u32_includes_patch() {
885        assert_ne!(
886            pack_runtime_version_u32("0.9.4").unwrap(),
887            pack_runtime_version_u32("0.9.99").unwrap()
888        );
889        assert_eq!(pack_runtime_version_u32("1.2.3").unwrap(), 0x01020003);
890    }
891
892    #[cfg(feature = "pq-proof")]
893    #[test]
894    fn test_pq_proof_envelope_hybrid_sign_verify() {
895        let src = r#"
896RULE CRUE_003 VERSION 1.0
897WHEN
898    agent.requests_last_hour >= 10
899THEN
900    BLOCK WITH CODE "TEST"
901"#;
902        let ast = crue_dsl::parser::parse(src).unwrap();
903        let bytecode = crue_dsl::compiler::Compiler::compile(&ast).unwrap();
904        let req = crate::EvaluationRequest {
905            request_id: "r".into(),
906            agent_id: "a".into(),
907            agent_org: "o".into(),
908            agent_level: "l".into(),
909            mission_id: None,
910            mission_type: None,
911            query_type: None,
912            justification: None,
913            export_format: None,
914            result_limit: None,
915            requests_last_hour: 12,
916            requests_last_24h: 10,
917            results_last_query: 1,
918            account_department: None,
919            allowed_departments: vec![],
920            request_hour: 9,
921            is_within_mission_hours: true,
922        };
923        let ctx = EvaluationContext::from_request(&req);
924        let binding =
925            ProofBinding::create(&bytecode, &req, &ctx, Decision::Block, "mock-crypto").unwrap();
926
927        let signer = pqcrypto::hybrid::HybridSigner::new(pqcrypto::DilithiumLevel::Dilithium2);
928        let kp = signer.generate_keypair().unwrap();
929        let pk = kp.public_key();
930        let envelope =
931            PqProofEnvelope::sign_hybrid(binding, "pq-proof-key-1", &signer, &kp).unwrap();
932        assert_eq!(envelope.pq_backend_id, signer.backend_id());
933        assert!(envelope.verify_hybrid(&pk).unwrap());
934    }
935}