1use 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#[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#[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#[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 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 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 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 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}