1use std::collections::{BTreeMap, BTreeSet};
10use std::fmt;
11
12use serde::de::{MapAccess, SeqAccess, Visitor};
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15use sha2::{Digest, Sha256};
16
17use crate::{
18 DecisionId, EngineInvocationIdV1, EnginePolicyDecisionV1, InvocationCapabilityBindingV1,
19 InvocationSourceBindingV1, InvocationSourceRoleV1, PlanId, ProtocolReference, Sha256Digest,
20 TaskId, UtcTimestamp, ValidationError, deserialize_schema_version,
21 validate_bounded_opaque_identifier, validate_schema_version,
22};
23
24pub const INVOCATION_CONTEXT_BINDING_SIGNATURE_DOMAIN: &[u8] =
26 b"leanctx/invocation-context-binding/v1\0";
27
28pub const MAX_INVOCATION_CONTEXT_BINDING_ITEMS: usize = 64;
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct InvocationContextBindingSignerV1 {
35 pub algorithm: String,
36 pub key_id: String,
37 pub public_key_digest: Sha256Digest,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct InvocationContextBindingV1 {
44 #[serde(deserialize_with = "deserialize_schema_version")]
45 pub schema_version: u32,
46 pub admission_id: DecisionId,
47 pub session_identity_ref: Sha256Digest,
48 pub task_id: TaskId,
49 pub task_ref: Sha256Digest,
50 pub plan_id: PlanId,
51 pub plan_ref: Sha256Digest,
52 pub invocation_id: EngineInvocationIdV1,
53 pub invocation_ref: Sha256Digest,
54 pub policy_ref: ProtocolReference,
55 pub policy_digest: Sha256Digest,
56 pub decision: EnginePolicyDecisionV1,
57 pub source_bindings: Vec<InvocationSourceBindingV1>,
58 pub capability_bindings: Vec<InvocationCapabilityBindingV1>,
59 pub issued_at: UtcTimestamp,
60 pub not_before: UtcTimestamp,
61 pub expires_at: UtcTimestamp,
62 pub signer: InvocationContextBindingSignerV1,
63 pub signature: String,
64}
65
66impl InvocationContextBindingV1 {
67 pub const SCHEMA_VERSION: u32 = 1;
69
70 pub fn canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
72 self.validate()?;
73 canonical_value_without_signature(self, false)
74 }
75
76 pub fn canonical_json(&self) -> Result<String, ValidationError> {
78 String::from_utf8(self.canonical_bytes()?).map_err(|error| {
79 ValidationError::new(format!(
80 "invocation context binding canonical UTF-8: {error}"
81 ))
82 })
83 }
84
85 pub fn unsigned_canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
87 self.validate_unsigned()?;
88 canonical_value_without_signature(self, true)
89 }
90
91 pub fn signing_bytes(&self) -> Result<Vec<u8>, ValidationError> {
93 let unsigned = self.unsigned_canonical_bytes()?;
94 let mut bytes =
95 Vec::with_capacity(INVOCATION_CONTEXT_BINDING_SIGNATURE_DOMAIN.len() + unsigned.len());
96 bytes.extend_from_slice(INVOCATION_CONTEXT_BINDING_SIGNATURE_DOMAIN);
97 bytes.extend_from_slice(&unsigned);
98 Ok(bytes)
99 }
100
101 pub fn signature_bytes(&self) -> Result<Vec<u8>, ValidationError> {
103 self.signing_bytes()
104 }
105
106 pub fn digest(&self) -> Result<Sha256Digest, ValidationError> {
108 let bytes = self.canonical_bytes()?;
109 digest_bytes(&bytes)
110 }
111
112 pub fn derived_binding_digest(&self) -> Result<Sha256Digest, ValidationError> {
114 self.digest()
115 }
116
117 pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
119 let value = strict_json_value(bytes)?;
120 let binding = serde_json::from_value::<Self>(value).map_err(|error| {
121 ValidationError::new(format!("decode invocation context binding: {error}"))
122 })?;
123 if binding.canonical_bytes()? != bytes {
124 return Err(ValidationError::new(
125 "invocation context binding JSON is not canonical UTF-8, compactness or key order",
126 ));
127 }
128 Ok(binding)
129 }
130
131 pub fn from_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
133 Self::from_canonical_bytes(bytes)
134 }
135
136 pub fn validate(&self) -> Result<(), ValidationError> {
138 self.validate_unsigned()?;
139 validate_base64_signature(&self.signature)?;
140 Ok(())
141 }
142
143 fn validate_unsigned(&self) -> Result<(), ValidationError> {
144 validate_schema_version(self.schema_version)?;
145 validate_bounded_binding_identifier(self.admission_id.as_str(), "admission_id")?;
146 validate_bounded_binding_identifier(self.task_id.as_str(), "task_id")?;
147 validate_bounded_binding_identifier(self.plan_id.as_str(), "plan_id")?;
148 validate_bounded_binding_identifier(self.invocation_id.as_str(), "invocation_id")?;
149 validate_schema_digest(&self.session_identity_ref, "session_identity_ref")?;
150 validate_schema_digest(&self.task_ref, "task_ref")?;
151 validate_schema_digest(&self.plan_ref, "plan_ref")?;
152 validate_schema_digest(&self.invocation_ref, "invocation_ref")?;
153 validate_manifest_reference(&self.policy_ref, "policy_ref")?;
154 validate_schema_digest(&self.policy_digest, "policy_digest")?;
155 if self.decision != EnginePolicyDecisionV1::Admitted {
156 return Err(ValidationError::new(
157 "InvocationContextBindingV1 decision must be admitted",
158 ));
159 }
160 validate_source_bindings(&self.source_bindings)?;
161 validate_capability_bindings(&self.capability_bindings)?;
162 if self.not_before > self.issued_at {
163 return Err(ValidationError::new(
164 "InvocationContextBindingV1 not_before must be at or before issued_at",
165 ));
166 }
167 if self.issued_at >= self.expires_at {
168 return Err(ValidationError::new(
169 "InvocationContextBindingV1 issued_at must be before expires_at",
170 ));
171 }
172 validate_signer(&self.signer)?;
173 Ok(())
174 }
175}
176
177fn validate_signer(signer: &InvocationContextBindingSignerV1) -> Result<(), ValidationError> {
178 if signer.algorithm != "ed25519" {
179 return Err(ValidationError::new(
180 "InvocationContextBindingSignerV1 algorithm must be ed25519",
181 ));
182 }
183 reject_binding_feff(&signer.key_id, "key_id")?;
184 validate_key_id(&signer.key_id)?;
185 validate_schema_digest(&signer.public_key_digest, "public_key_digest")
189}
190
191fn validate_bounded_binding_identifier(value: &str, field: &str) -> Result<(), ValidationError> {
192 reject_binding_feff(value, field)?;
193 validate_bounded_opaque_identifier(value, field)
194}
195
196fn reject_binding_feff(value: &str, field: &str) -> Result<(), ValidationError> {
197 if value.contains('\u{feff}') {
198 return Err(ValidationError::new(format!(
199 "{field} must not contain U+FEFF"
200 )));
201 }
202 Ok(())
203}
204
205fn validate_key_id(value: &str) -> Result<(), ValidationError> {
206 if value.is_empty()
207 || value.len() > 128
208 || !value.is_ascii()
209 || !value.as_bytes()[0].is_ascii_alphanumeric()
210 || !value
211 .bytes()
212 .all(|byte| byte.is_ascii_alphanumeric() || b"._:/-".contains(&byte))
213 || value.starts_with("base64:")
214 || value.starts_with("hex:")
215 {
216 return Err(ValidationError::new(
217 "InvocationContextBindingSignerV1 key_id must be a bounded identifier, not key material",
218 ));
219 }
220 Ok(())
221}
222
223fn validate_schema_digest(value: &Sha256Digest, field: &str) -> Result<(), ValidationError> {
224 if value.as_str().len() != "sha256:".len() + 64 {
225 return Err(ValidationError::new(format!(
226 "{field} must be a SHA-256 digest"
227 )));
228 }
229 Ok(())
230}
231
232fn validate_manifest_reference(
233 reference: &ProtocolReference,
234 field: &str,
235) -> Result<(), ValidationError> {
236 if reference.as_str().contains('\u{feff}') {
237 return Err(ValidationError::new(format!(
238 "{field} must not contain U+FEFF"
239 )));
240 }
241 if reference.as_str().trim().is_empty() {
242 return Err(ValidationError::new(format!("{field} must not be empty")));
243 }
244 Ok(())
245}
246
247fn validate_source_bindings(bindings: &[InvocationSourceBindingV1]) -> Result<(), ValidationError> {
248 if bindings.is_empty() || bindings.len() > MAX_INVOCATION_CONTEXT_BINDING_ITEMS {
249 return Err(ValidationError::new(format!(
250 "source_bindings must contain 1..={MAX_INVOCATION_CONTEXT_BINDING_ITEMS} entries"
251 )));
252 }
253 let mut refs = BTreeSet::new();
254 let mut digests = BTreeSet::new();
255 let mut input_count = 0usize;
256 let mut previous_ref = None;
257 for binding in bindings {
258 validate_manifest_reference(&binding.source_ref, "source_ref")?;
259 if !refs.insert(binding.source_ref.as_str()) {
260 return Err(ValidationError::new(
261 "source_bindings source_ref values must be unique",
262 ));
263 }
264 if !digests.insert(binding.digest.as_str()) {
265 return Err(ValidationError::new(
266 "source_bindings digest values must be unique",
267 ));
268 }
269 if let Some(previous) = previous_ref {
270 if previous >= binding.source_ref.as_str() {
271 return Err(ValidationError::new(
272 "source_bindings must be sorted by source_ref",
273 ));
274 }
275 }
276 previous_ref = Some(binding.source_ref.as_str());
277 if binding.role == InvocationSourceRoleV1::Input {
278 input_count += 1;
279 }
280 }
281 if input_count != 1 {
282 return Err(ValidationError::new(
283 "source_bindings must contain exactly one input",
284 ));
285 }
286 Ok(())
287}
288
289fn validate_capability_bindings(
290 bindings: &[InvocationCapabilityBindingV1],
291) -> Result<(), ValidationError> {
292 if bindings.len() != 1 {
293 return Err(ValidationError::new(format!(
294 "capability_bindings must contain exactly one entry in V1 (maximum {MAX_INVOCATION_CONTEXT_BINDING_ITEMS})"
295 )));
296 }
297 let mut keys = BTreeSet::new();
298 let mut digests = BTreeSet::new();
299 let mut previous_key = None;
300 for binding in bindings {
301 validate_bounded_binding_identifier(binding.capability_id.as_str(), "capability_id")?;
302 reject_binding_feff(binding.capability_version.as_str(), "capability_version")?;
303 let key = (
304 binding.capability_id.as_str(),
305 binding.capability_version.as_str(),
306 );
307 if !keys.insert(key) {
308 return Err(ValidationError::new(
309 "capability_bindings capability_id/version pairs must be unique",
310 ));
311 }
312 if !digests.insert(binding.manifest_digest.as_str()) {
313 return Err(ValidationError::new(
314 "capability_bindings manifest_digest values must be unique",
315 ));
316 }
317 let ordering_key = (
318 binding.capability_id.as_str(),
319 binding.capability_version.as_str(),
320 );
321 if let Some(previous) = previous_key {
322 if previous >= ordering_key {
323 return Err(ValidationError::new(
324 "capability_bindings must be sorted by capability_id and capability_version",
325 ));
326 }
327 }
328 previous_key = Some(ordering_key);
329 }
330 Ok(())
331}
332
333fn validate_base64_signature(value: &str) -> Result<(), ValidationError> {
334 if value.len() != 88 || !value.ends_with("==") {
335 return Err(ValidationError::new(
336 "InvocationContextBindingV1 signature must be canonical base64 Ed25519 bytes",
337 ));
338 }
339 let bytes = value.as_bytes();
340 if !bytes[..86]
341 .iter()
342 .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'+' || *byte == b'/')
343 {
344 return Err(ValidationError::new(
345 "InvocationContextBindingV1 signature contains invalid base64 characters",
346 ));
347 }
348 if base64_value(bytes[85]).is_none_or(|last| last & 0x0f != 0) {
351 return Err(ValidationError::new(
352 "InvocationContextBindingV1 signature has non-zero base64 pad bits",
353 ));
354 }
355 Ok(())
356}
357
358fn base64_value(byte: u8) -> Option<u8> {
359 match byte {
360 b'A'..=b'Z' => Some(byte - b'A'),
361 b'a'..=b'z' => Some(byte - b'a' + 26),
362 b'0'..=b'9' => Some(byte - b'0' + 52),
363 b'+' => Some(62),
364 b'/' => Some(63),
365 _ => None,
366 }
367}
368
369fn canonical_value_without_signature<T: Serialize>(
370 value: &T,
371 omit_signature: bool,
372) -> Result<Vec<u8>, ValidationError> {
373 let mut value = serde_json::to_value(value).map_err(|error| {
374 ValidationError::new(format!("serialize invocation context binding: {error}"))
375 })?;
376 if omit_signature {
377 let Value::Object(object) = &mut value else {
378 return Err(ValidationError::new(
379 "invocation context binding must serialize as a JSON object",
380 ));
381 };
382 object.remove("signature");
383 }
384 serde_json::to_vec(&sort_json(value)).map_err(|error| {
385 ValidationError::new(format!("canonicalize invocation context binding: {error}"))
386 })
387}
388
389fn sort_json(value: Value) -> Value {
390 match value {
391 Value::Object(object) => Value::Object(
392 object
393 .into_iter()
394 .map(|(key, value)| (key, sort_json(value)))
395 .collect::<BTreeMap<_, _>>()
396 .into_iter()
397 .collect(),
398 ),
399 Value::Array(values) => Value::Array(values.into_iter().map(sort_json).collect()),
400 scalar => scalar,
401 }
402}
403
404struct StrictJsonValue(Value);
406
407impl<'de> Deserialize<'de> for StrictJsonValue {
408 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
409 where
410 D: serde::Deserializer<'de>,
411 {
412 deserializer.deserialize_any(StrictJsonVisitor)
413 }
414}
415
416struct StrictJsonVisitor;
417
418impl<'de> Visitor<'de> for StrictJsonVisitor {
419 type Value = StrictJsonValue;
420
421 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
422 formatter.write_str("a canonical JSON value with unique object keys")
423 }
424
425 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
426 where
427 E: serde::de::Error,
428 {
429 Ok(StrictJsonValue(Value::Bool(value)))
430 }
431
432 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
433 where
434 E: serde::de::Error,
435 {
436 Ok(StrictJsonValue(Value::Number(value.into())))
437 }
438
439 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
440 where
441 E: serde::de::Error,
442 {
443 Ok(StrictJsonValue(Value::Number(value.into())))
444 }
445
446 fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E>
447 where
448 E: serde::de::Error,
449 {
450 Err(E::custom(
451 "floating-point JSON numbers are not canonical binding values",
452 ))
453 }
454
455 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
456 where
457 E: serde::de::Error,
458 {
459 Ok(StrictJsonValue(Value::String(value.to_owned())))
460 }
461
462 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
463 where
464 E: serde::de::Error,
465 {
466 Ok(StrictJsonValue(Value::String(value)))
467 }
468
469 fn visit_none<E>(self) -> Result<Self::Value, E>
470 where
471 E: serde::de::Error,
472 {
473 Ok(StrictJsonValue(Value::Null))
474 }
475
476 fn visit_unit<E>(self) -> Result<Self::Value, E>
477 where
478 E: serde::de::Error,
479 {
480 Ok(StrictJsonValue(Value::Null))
481 }
482
483 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
484 where
485 A: SeqAccess<'de>,
486 {
487 let mut values = Vec::new();
488 while let Some(value) = seq.next_element::<StrictJsonValue>()? {
489 values.push(value.0);
490 }
491 Ok(StrictJsonValue(Value::Array(values)))
492 }
493
494 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
495 where
496 A: MapAccess<'de>,
497 {
498 let mut object = Map::new();
499 while let Some(key) = map.next_key::<String>()? {
500 if object.contains_key(&key) {
501 return Err(serde::de::Error::custom(format!(
502 "duplicate JSON object key {key:?}"
503 )));
504 }
505 let value = map.next_value::<StrictJsonValue>()?;
506 object.insert(key, value.0);
507 }
508 Ok(StrictJsonValue(Value::Object(object)))
509 }
510}
511
512fn strict_json_value(bytes: &[u8]) -> Result<Value, ValidationError> {
513 let mut deserializer = serde_json::Deserializer::from_slice(bytes);
514 let value = StrictJsonValue::deserialize(&mut deserializer)
515 .map_err(|error| ValidationError::new(format!("decode canonical binding JSON: {error}")))?
516 .0;
517 deserializer.end().map_err(|error| {
518 ValidationError::new(format!("trailing canonical binding JSON: {error}"))
519 })?;
520 Ok(value)
521}
522
523fn digest_bytes(bytes: &[u8]) -> Result<Sha256Digest, ValidationError> {
524 let digest = Sha256::digest(bytes);
525 let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
526 encoded.push_str("sha256:");
527 for byte in digest {
528 use std::fmt::Write as _;
529 write!(encoded, "{byte:02x}")
530 .map_err(|error| ValidationError::new(format!("encode binding digest: {error}")))?;
531 }
532 Sha256Digest::new(encoded)
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 fn digest(ch: char) -> Sha256Digest {
540 Sha256Digest::new(format!("sha256:{}", ch.to_string().repeat(64))).unwrap()
541 }
542
543 fn source_ref(value: &str) -> ProtocolReference {
544 ProtocolReference::new(value).unwrap()
545 }
546
547 fn valid_binding() -> InvocationContextBindingV1 {
548 InvocationContextBindingV1 {
549 schema_version: 1,
550 admission_id: DecisionId::new("admission-1").unwrap(),
551 session_identity_ref: digest('a'),
552 task_id: TaskId::new("task-1").unwrap(),
553 task_ref: digest('b'),
554 plan_id: PlanId::new("plan-1").unwrap(),
555 plan_ref: digest('c'),
556 invocation_id: EngineInvocationIdV1::new("invocation-1").unwrap(),
557 invocation_ref: digest('d'),
558 policy_ref: source_ref("policy:invocation-admission"),
559 policy_digest: digest('e'),
560 decision: EnginePolicyDecisionV1::Admitted,
561 source_bindings: vec![InvocationSourceBindingV1 {
562 source_ref: source_ref("source:input"),
563 digest: digest('f'),
564 role: InvocationSourceRoleV1::Input,
565 }],
566 capability_bindings: vec![InvocationCapabilityBindingV1 {
567 capability_id: crate::CapabilityId::new("capability:engine").unwrap(),
568 capability_version: crate::SemanticVersion::new("1.0.0").unwrap(),
569 manifest_digest: digest('1'),
570 }],
571 issued_at: UtcTimestamp::new("2026-08-23T12:00:00Z").unwrap(),
572 not_before: UtcTimestamp::new("2026-08-23T11:59:00Z").unwrap(),
573 expires_at: UtcTimestamp::new("2026-08-23T12:05:00Z").unwrap(),
574 signer: InvocationContextBindingSignerV1 {
575 algorithm: "ed25519".to_owned(),
576 key_id: "test-key".to_owned(),
577 public_key_digest: digest('2'),
578 },
579 signature: "A".repeat(86) + "==",
580 }
581 }
582
583 #[test]
584 fn canonical_round_trip_and_signature_domain_are_stable() {
585 let binding = valid_binding();
586 binding.validate().unwrap();
587 let bytes = binding.canonical_bytes().unwrap();
588 let decoded = InvocationContextBindingV1::from_canonical_bytes(&bytes).unwrap();
589 assert_eq!(decoded, binding);
590 let golden =
591 include_bytes!("../../../../docs/contracts/invocation-context-binding/v1/valid.json");
592 let golden = golden.strip_suffix(b"\n").unwrap_or(golden);
593 assert_eq!(
594 InvocationContextBindingV1::from_canonical_bytes(golden).unwrap(),
595 binding
596 );
597 let signing = include_bytes!(
598 "../../../../docs/contracts/invocation-context-binding/v1/canonical-signing.json"
599 );
600 let signing = signing.strip_suffix(b"\n").unwrap_or(signing);
601 assert_eq!(binding.unsigned_canonical_bytes().unwrap(), signing);
602 let signing_bytes = binding.signing_bytes().unwrap();
603 assert!(signing_bytes.starts_with(INVOCATION_CONTEXT_BINDING_SIGNATURE_DOMAIN));
604 assert_eq!(
605 signing_bytes[INVOCATION_CONTEXT_BINDING_SIGNATURE_DOMAIN.len() - 1],
606 0
607 );
608 let expected_hex = include_bytes!(
609 "../../../../docs/contracts/invocation-context-binding/v1/signing-bytes.hex"
610 );
611 let expected_hex = expected_hex.strip_suffix(b"\n").unwrap_or(expected_hex);
612 let expected_signing_bytes = decode_hex(expected_hex);
613 assert_eq!(signing_bytes, expected_signing_bytes);
614 let expected_hash = include_bytes!(
615 "../../../../docs/contracts/invocation-context-binding/v1/signing-bytes.sha256"
616 );
617 let expected_hash = expected_hash.strip_suffix(b"\n").unwrap_or(expected_hash);
618 let actual_hash = Sha256::digest(&signing_bytes);
619 let actual_hash = actual_hash.iter().fold(String::new(), |mut value, byte| {
620 use std::fmt::Write as _;
621 write!(value, "{byte:02x}").unwrap();
622 value
623 });
624 assert_eq!(actual_hash.as_bytes(), expected_hash);
625 assert_ne!(
626 binding.canonical_bytes().unwrap(),
627 binding.unsigned_canonical_bytes().unwrap()
628 );
629 assert_eq!(binding.digest().unwrap(), decoded.digest().unwrap());
630 }
631
632 #[test]
633 fn strict_decoder_rejects_duplicate_unknown_float_whitespace_and_mutations() {
634 let binding = valid_binding();
635 let canonical = binding.canonical_bytes().unwrap();
636 let mut duplicate = canonical.clone();
637 duplicate.pop();
638 duplicate.extend_from_slice(b",\"schema_version\":1}");
639 assert!(InvocationContextBindingV1::from_canonical_bytes(&duplicate).is_err());
640
641 let mut whitespace = b" \n".to_vec();
642 whitespace.extend_from_slice(&canonical);
643 assert!(InvocationContextBindingV1::from_canonical_bytes(&whitespace).is_err());
644 assert!(strict_json_value(br#"{"schema_version":1.0}"#).is_err());
645
646 let mut unknown = canonical.clone();
647 unknown.pop();
648 unknown.extend_from_slice(b",\"unknown\":true}");
649 assert!(InvocationContextBindingV1::from_canonical_bytes(&unknown).is_err());
650 }
651
652 #[test]
653 fn unicode_fixture_uses_utf8_without_optional_escapes() {
654 let bytes = include_bytes!(
655 "../../../../docs/contracts/invocation-context-binding/v1/valid-unicode.json"
656 );
657 let bytes = bytes.strip_suffix(b"\n").unwrap_or(bytes);
658 let binding = InvocationContextBindingV1::from_canonical_bytes(bytes).unwrap();
659 let canonical = binding.canonical_bytes().unwrap();
660 assert!(
661 canonical
662 .windows("source:é".len())
663 .any(|w| w == "source:é".as_bytes())
664 );
665 assert!(!canonical.windows(br"\u00e9".len()).any(|w| w == br"\u00e9"));
666 }
667
668 #[test]
669 fn signature_metadata_and_admission_invariants_are_strict() {
670 let mut binding = valid_binding();
671 binding.signature = "A".repeat(85) + "B==";
672 assert!(binding.validate().is_err());
673 binding = valid_binding();
674 binding.signer.algorithm = "ed25519ph".to_owned();
675 assert!(binding.validate().is_err());
676 binding = valid_binding();
677 binding.decision = EnginePolicyDecisionV1::Rejected;
678 assert!(binding.validate().is_err());
679 binding = valid_binding();
680 binding.expires_at = binding.issued_at.clone();
681 assert!(binding.validate().is_err());
682 binding = valid_binding();
683 binding.source_bindings.push(InvocationSourceBindingV1 {
684 source_ref: source_ref("source:other"),
685 digest: digest('3'),
686 role: InvocationSourceRoleV1::Context,
687 });
688 binding.source_bindings.reverse();
689 assert!(binding.validate().is_err());
690 }
691
692 #[test]
693 fn bounded_ids_reject_feff_on_every_binding_identity_path() {
694 let mut binding = valid_binding();
695 binding.admission_id = DecisionId::new("admission:\u{feff}").unwrap();
696 assert!(binding.validate().is_err());
697 let mut binding = valid_binding();
698 binding.task_id = TaskId::new("task:\u{feff}").unwrap();
699 assert!(binding.validate().is_err());
700 let mut binding = valid_binding();
701 binding.plan_id = PlanId::new("plan:\u{feff}").unwrap();
702 assert!(binding.validate().is_err());
703 let mut binding = valid_binding();
704 binding.invocation_id = EngineInvocationIdV1::new("invocation:\u{feff}").unwrap();
705 assert!(binding.validate().is_err());
706 let mut binding = valid_binding();
707 binding.signer.key_id = "key:\u{feff}".to_owned();
708 assert!(binding.validate().is_err());
709 let mut binding = valid_binding();
710 binding.policy_ref = source_ref("policy:\u{feff}ref");
711 assert!(binding.validate().is_err());
712 let mut binding = valid_binding();
713 binding.source_bindings[0].source_ref = source_ref("source:\u{feff}input");
714 assert!(binding.validate().is_err());
715 let mut binding = valid_binding();
716 binding.capability_bindings[0].capability_id =
717 crate::CapabilityId::new("capability:\u{feff}engine").unwrap();
718 assert!(binding.validate().is_err());
719 assert!(crate::SemanticVersion::new("1.0.0\u{feff}").is_err());
720 }
721
722 #[test]
723 fn wrong_public_key_metadata_changes_coverage_without_trust_claim() {
724 let binding = valid_binding();
725 let expected = binding.signing_bytes().unwrap();
726 let mut wrong_key = binding.clone();
727 wrong_key.signer.public_key_digest = digest('3');
728 wrong_key.validate().unwrap();
729 assert_ne!(wrong_key.signing_bytes().unwrap(), expected);
730 }
733
734 #[test]
735 fn signature_mutation_changes_full_digest_not_unsigned_coverage() {
736 let binding = valid_binding();
737 let unsigned = binding.unsigned_canonical_bytes().unwrap();
738 let original_digest = binding.digest().unwrap();
739 let mut mutated = binding.clone();
740 mutated.signature.replace_range(0..1, "B");
741 assert_ne!(mutated.digest().unwrap(), original_digest);
742 assert_eq!(mutated.unsigned_canonical_bytes().unwrap(), unsigned);
743 }
744
745 #[test]
746 fn capability_cardinality_is_exactly_one_in_v1() {
747 let mut binding = valid_binding();
748 assert!(binding.validate().is_ok());
749 binding
750 .capability_bindings
751 .push(InvocationCapabilityBindingV1 {
752 capability_id: crate::CapabilityId::new("capability:other").unwrap(),
753 capability_version: crate::SemanticVersion::new("1.0.0").unwrap(),
754 manifest_digest: digest('3'),
755 });
756 assert_eq!(binding.capability_bindings.len(), 2);
757 assert!(binding.validate().is_err());
758 }
759
760 #[test]
761 fn adversarial_fixture_set_is_rejected() {
762 for name in [
763 "invalid-duplicate-key.json",
764 "invalid-schema-version-float.json",
765 "invalid-unknown-field.json",
766 "invalid-signature-pad-bits.json",
767 "invalid-time-order.json",
768 "invalid-duplicate-source.json",
769 "invalid-feff-task-id.json",
770 ] {
771 let bytes: &[u8] = match name {
772 "invalid-duplicate-key.json" => include_bytes!(
773 "../../../../docs/contracts/invocation-context-binding/v1/invalid-duplicate-key.json"
774 ),
775 "invalid-schema-version-float.json" => include_bytes!(
776 "../../../../docs/contracts/invocation-context-binding/v1/invalid-schema-version-float.json"
777 ),
778 "invalid-unknown-field.json" => include_bytes!(
779 "../../../../docs/contracts/invocation-context-binding/v1/invalid-unknown-field.json"
780 ),
781 "invalid-signature-pad-bits.json" => include_bytes!(
782 "../../../../docs/contracts/invocation-context-binding/v1/invalid-signature-pad-bits.json"
783 ),
784 "invalid-time-order.json" => include_bytes!(
785 "../../../../docs/contracts/invocation-context-binding/v1/invalid-time-order.json"
786 ),
787 "invalid-duplicate-source.json" => include_bytes!(
788 "../../../../docs/contracts/invocation-context-binding/v1/invalid-duplicate-source.json"
789 ),
790 "invalid-feff-task-id.json" => include_bytes!(
791 "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-task-id.json"
792 ),
793 _ => unreachable!(),
794 };
795 assert!(
796 InvocationContextBindingV1::from_canonical_bytes(bytes).is_err(),
797 "fixture {name} must be rejected"
798 );
799 }
800 }
801
802 #[test]
803 fn additional_adversarial_fixtures_are_rejected() {
804 let fixtures: &[&[u8]] = &[
805 include_bytes!(
806 "../../../../docs/contracts/invocation-context-binding/v1/invalid-algorithm.json"
807 ) as &[u8],
808 include_bytes!(
809 "../../../../docs/contracts/invocation-context-binding/v1/invalid-signature-length.json"
810 ),
811 include_bytes!(
812 "../../../../docs/contracts/invocation-context-binding/v1/invalid-zero-input.json"
813 ),
814 include_bytes!(
815 "../../../../docs/contracts/invocation-context-binding/v1/invalid-multiple-inputs.json"
816 ),
817 include_bytes!(
818 "../../../../docs/contracts/invocation-context-binding/v1/invalid-extra-capability.json"
819 ),
820 include_bytes!(
821 "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-admission-id.json"
822 ),
823 include_bytes!(
824 "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-plan-id.json"
825 ),
826 include_bytes!(
827 "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-invocation-id.json"
828 ),
829 include_bytes!(
830 "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-policy-reference.json"
831 ),
832 include_bytes!(
833 "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-source-reference.json"
834 ),
835 include_bytes!(
836 "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-capability-id.json"
837 ),
838 include_bytes!(
839 "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-capability-version.json"
840 ),
841 include_bytes!(
842 "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-key-id.json"
843 ),
844 include_bytes!(
845 "../../../../docs/contracts/invocation-context-binding/v1/invalid-key-id-material.json"
846 ) as &[u8],
847 ];
848 for bytes in fixtures {
849 assert!(InvocationContextBindingV1::from_canonical_bytes(bytes).is_err());
850 }
851 let error = InvocationContextBindingV1::from_canonical_bytes(include_bytes!(
852 "../../../../docs/contracts/invocation-context-binding/v1/invalid-signature-pad-bits.json"
853 ))
854 .unwrap_err();
855 assert!(error.to_string().contains("pad bits"));
856 }
857
858 fn decode_hex(bytes: &[u8]) -> Vec<u8> {
859 fn nibble(byte: u8) -> u8 {
860 match byte {
861 b'0'..=b'9' => byte - b'0',
862 b'a'..=b'f' => byte - b'a' + 10,
863 _ => panic!("invalid hex fixture"),
864 }
865 }
866 bytes
867 .chunks_exact(2)
868 .map(|pair| (nibble(pair[0]) << 4) | nibble(pair[1]))
869 .collect()
870 }
871
872 #[test]
873 fn schema_declares_semantic_and_runtime_conformance_boundaries() {
874 let schema: Value = serde_json::from_slice(include_bytes!(
875 "../../../../docs/contracts/invocation-context-binding-v1.schema.json"
876 ))
877 .unwrap();
878 assert_eq!(schema["x-conformance"]["mode"], "three_stage");
879 assert_eq!(
880 schema["x-conformance"]["semantic_validation_required"],
881 true
882 );
883 assert_eq!(
884 schema["x-conformance"]["cross_artifact_join_required"],
885 true
886 );
887 assert_eq!(schema["x-conformance"]["signature"]["decoded_bytes"], 64);
888 assert_eq!(
889 schema["x-conformance"]["signature_domain_terminator_hex"],
890 "00"
891 );
892 assert_eq!(
893 schema["x-conformance"]["canonical_json"]["encoding"],
894 "UTF-8"
895 );
896 assert_eq!(schema["properties"]["capability_bindings"]["minItems"], 1);
897 assert_eq!(schema["properties"]["capability_bindings"]["maxItems"], 1);
898 let requirements = schema["x-conformance"]["stages"][2]["requirements"]
899 .as_array()
900 .unwrap();
901 assert!(requirements.iter().any(|requirement| {
902 requirement
903 .as_str()
904 .is_some_and(|value| value.contains("policy_digest"))
905 }));
906 assert!(
907 schema["x-conformance"]["runtime_gates_not_in_decoder"]
908 .as_array()
909 .unwrap()
910 .iter()
911 .any(|value| value == "one_time_consumption")
912 );
913 }
914}