1use alloc::{string::String, vec::Vec};
9use coset::cbor::Value;
10use serde::Serialize;
11
12use crate::receipt_cbor::{Budget, Role};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum InspectionStatus {
18 Passed,
19 Failed,
20 Unsupported,
21 Unestablished,
22 NotEvaluated,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27pub struct InspectionFinding {
28 pub status: InspectionStatus,
29 pub code: &'static str,
30 pub detail: &'static str,
31 pub evidence_refs: Vec<&'static str>,
32}
33impl InspectionFinding {
34 fn new(status: InspectionStatus, code: &'static str, detail: &'static str) -> Self {
35 Self {
36 status,
37 code,
38 detail,
39 evidence_refs: alloc::vec!["encoded_receipt"],
40 }
41 }
42 fn passed() -> Self {
43 Self::new(
44 InspectionStatus::Passed,
45 "checked",
46 "Passed only this inspection dimension; unauthenticated.",
47 )
48 }
49 fn skipped() -> Self {
50 Self::new(
51 InspectionStatus::NotEvaluated,
52 "dependent_check_not_run",
53 "A prerequisite was not established; this is not a pass.",
54 )
55 }
56 fn later() -> Self {
57 Self::new(
58 InspectionStatus::NotEvaluated,
59 "outside_phase1",
60 "Phase 1 does not evaluate cryptography, trust, subject correspondence or application acceptance.",
61 )
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct InspectionLimits {
69 pub max_receipt_bytes: usize,
70 pub max_protected_bytes: usize,
71 pub max_signature_bytes: usize,
72 pub max_cbor_nesting: usize,
73 pub max_cbor_items: usize,
74 pub max_map_entries: usize,
75 pub max_proofs_per_receipt: usize,
76 pub max_path_nodes_per_proof: usize,
77 pub max_certificate_chain_length: usize,
78 pub max_certificate_bytes: usize,
79 pub max_claim_text_characters: usize,
80 pub max_tree_size: u64,
81}
82impl Default for InspectionLimits {
83 fn default() -> Self {
84 Self {
85 max_receipt_bytes: 1_048_576,
86 max_protected_bytes: 65_536,
87 max_signature_bytes: 1_024,
88 max_cbor_nesting: 16,
89 max_cbor_items: 4_096,
90 max_map_entries: 64,
91 max_proofs_per_receipt: 16,
92 max_path_nodes_per_proof: 64,
93 max_certificate_chain_length: 8,
94 max_certificate_bytes: 65_536,
95 max_claim_text_characters: 8_192,
96 max_tree_size: u64::MAX,
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
105pub struct InspectionPolicy {
106 pub strict_cross_map: bool,
107 pub limits: InspectionLimits,
108}
109impl InspectionPolicy {
110 pub const ID: &'static str = "pask71-local-ed25519-rfc9162/1";
111 pub const UNDERSTOOD_CRITICAL_LABELS: &'static [i128] = &[1, 2, 4, 15, 395];
112 pub const EXTERNAL_AAD: &'static [u8] = &[];
113 fn valid(&self) -> bool {
114 let d = InspectionLimits::default();
115 let l = &self.limits;
116 macro_rules! bounded { ($($f:ident),+) => { true $(&& l.$f > 0 && l.$f <= d.$f)+ }; }
117 bounded!(
118 max_receipt_bytes,
119 max_protected_bytes,
120 max_signature_bytes,
121 max_cbor_nesting,
122 max_cbor_items,
123 max_map_entries,
124 max_proofs_per_receipt,
125 max_path_nodes_per_proof,
126 max_certificate_chain_length,
127 max_certificate_bytes,
128 max_claim_text_characters,
129 max_tree_size
130 )
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
137pub struct UnauthenticatedReceiptClaims {
138 pub issuer: String,
139 pub subject: String,
140 pub authenticated: bool,
141}
142
143#[derive(Debug, Clone)]
148pub struct EnvelopeReport<'a> {
149 pub encoded_receipt: &'a [u8],
150 pub protected_bytes: Option<Vec<u8>>,
151 pub payload_bytes: Option<Vec<u8>>,
152 pub signature_bytes: Option<Vec<u8>>,
153 pub unauthenticated_claims: Option<UnauthenticatedReceiptClaims>,
154 pub effective_headers: Vec<(Value, Value)>,
155 pub structure: InspectionFinding,
156 pub required_claims: InspectionFinding,
157 pub support: InspectionFinding,
158 pub selected_policy: InspectionFinding,
159 pub policy_id: &'static str,
160 pub policy: InspectionPolicy,
161 pub cbor_items_inspected: usize,
162 pub ts_signature: InspectionFinding,
163 pub inclusion: InspectionFinding,
164 pub ts_identity_trust: InspectionFinding,
165 pub subject_policy: InspectionFinding,
166 pub application_policy: InspectionFinding,
167}
168
169#[derive(Clone, Copy, Debug)]
170pub(crate) enum Dimension {
171 Structure,
172 Claims,
173 Policy,
174}
175#[derive(Clone, Copy, Debug)]
176pub(crate) struct Problem {
177 dimension: Dimension,
178 code: &'static str,
179}
180impl Problem {
181 pub(crate) fn structure(code: &'static str) -> Self {
182 Self {
183 dimension: Dimension::Structure,
184 code,
185 }
186 }
187 pub(crate) fn policy(code: &'static str) -> Self {
188 Self {
189 dimension: Dimension::Policy,
190 code,
191 }
192 }
193 pub(crate) fn claims(code: &'static str) -> Self {
194 Self {
195 dimension: Dimension::Claims,
196 code,
197 }
198 }
199}
200
201#[must_use]
205pub fn inspect_scitt_receipt<'a>(
206 receipt_bytes: &'a [u8],
207 policy: &InspectionPolicy,
208) -> EnvelopeReport<'a> {
209 let mut report = EnvelopeReport {
210 encoded_receipt: receipt_bytes,
211 protected_bytes: None,
212 payload_bytes: None,
213 signature_bytes: None,
214 unauthenticated_claims: None,
215 effective_headers: Vec::new(),
216 structure: InspectionFinding::skipped(),
217 required_claims: InspectionFinding::skipped(),
218 support: InspectionFinding::skipped(),
219 selected_policy: InspectionFinding::passed(),
220 policy_id: InspectionPolicy::ID,
221 policy: policy.clone(),
222 cbor_items_inspected: 0,
223 ts_signature: InspectionFinding::later(),
224 inclusion: InspectionFinding::later(),
225 ts_identity_trust: InspectionFinding::later(),
226 subject_policy: InspectionFinding::later(),
227 application_policy: InspectionFinding::later(),
228 };
229 let mut budget = Budget {
230 limits: &policy.limits,
231 items: 0,
232 unprotected_x5t_invalid: false,
233 };
234 if let Err(problem) = inspect(&mut report, &mut budget) {
235 apply_problem(&mut report, problem);
236 }
237 report.cbor_items_inspected = budget.items;
238 report
239}
240fn apply_problem(report: &mut EnvelopeReport<'_>, problem: Problem) {
241 let finding = InspectionFinding::new(
242 InspectionStatus::Failed,
243 problem.code,
244 "Rejected in the named dimension; consult policy and exact input bytes.",
245 );
246 match problem.dimension {
247 Dimension::Structure => {
248 report.structure = finding;
249 report.required_claims = InspectionFinding::skipped();
250 report.support = InspectionFinding::skipped();
251 }
252 Dimension::Claims => report.required_claims = finding,
253 Dimension::Policy => report.selected_policy = finding,
254 }
255}
256
257fn decode(bytes: &[u8]) -> Result<Value, Problem> {
258 let mut cursor = bytes;
259 let value = coset::cbor::de::from_reader(&mut cursor)
260 .map_err(|_| Problem::structure("invalid_cbor"))?;
261 if !cursor.is_empty() {
262 return Err(Problem::structure("trailing_cbor"));
263 }
264 Ok(value)
265}
266
267pub(crate) fn check_unique_maps(value: &Value) -> Result<(), &'static str> {
271 match value {
272 Value::Map(entries) => {
273 for (i, (key, val)) in entries.iter().enumerate() {
274 if entries[..i].iter().any(|(other, _)| equal_key(key, other)) {
275 return Err("duplicate_key");
276 }
277 check_unique_maps(key)?;
278 check_unique_maps(val)?;
279 }
280 }
281 Value::Array(items) => {
282 for item in items {
283 check_unique_maps(item)?;
284 }
285 }
286 Value::Tag(_, value) => check_unique_maps(value)?,
287 _ => (),
288 }
289 Ok(())
290}
291fn equal_key(a: &Value, b: &Value) -> bool {
292 match (a, b) {
293 (Value::Map(a), Value::Map(b)) => {
294 a.len() == b.len()
295 && a.iter().all(|(ak, av)| {
296 b.iter()
297 .any(|(bk, bv)| equal_key(ak, bk) && equal_key(av, bv))
298 })
299 }
300 (Value::Array(a), Value::Array(b)) => {
301 a.len() == b.len() && a.iter().zip(b).all(|(a, b)| equal_key(a, b))
302 }
303 (Value::Tag(a, av), Value::Tag(b, bv)) => a == b && equal_key(av, bv),
304 (Value::Float(a), Value::Float(b)) => a == b || (a.is_nan() && b.is_nan()),
305 _ => a == b,
306 }
307}
308fn integer(value: &Value) -> Option<i128> {
309 if let Value::Integer(n) = value {
310 Some((*n).into())
311 } else {
312 None
313 }
314}
315fn get(map: &[(Value, Value)], key: i128) -> Option<&Value> {
316 map.iter()
317 .find(|(k, _)| integer(k) == Some(key))
318 .map(|(_, v)| v)
319}
320fn labels(map: &[(Value, Value)]) -> Result<(), Problem> {
321 if map
322 .iter()
323 .any(|(key, _)| !matches!(key, Value::Integer(_) | Value::Text(_)))
324 {
325 return Err(Problem::structure("header_label_type"));
326 }
327 Ok(())
328}
329
330fn inspect(report: &mut EnvelopeReport<'_>, budget: &mut Budget<'_>) -> Result<(), Problem> {
331 if !report.policy.valid() {
332 return Err(Problem::policy("invalid_policy_limits"));
333 }
334 if report.encoded_receipt.len() > budget.limits.max_receipt_bytes {
335 return Err(Problem::policy("receipt_byte_limit"));
336 }
337 budget.scan(
338 report.encoded_receipt,
339 0,
340 Role::Envelope,
341 "outer_trailing_data",
342 )?;
343 let value = decode(report.encoded_receipt)?;
344 check_unique_maps(&value).map_err(Problem::structure)?;
345 let inner = match &value {
346 Value::Tag(18, inner) => inner,
347 Value::Tag(_, _) => return Err(Problem::structure("wrong_receipt_tag")),
348 _ => return Err(Problem::structure("missing_receipt_tag")),
349 };
350 let Value::Array(items) = inner.as_ref() else {
351 return Err(Problem::structure("element_count"));
352 };
353 let [p, u, m, s] = items.as_slice() else {
354 return Err(Problem::structure("element_count"));
355 };
356 let Value::Bytes(p) = p else {
357 return Err(Problem::structure("protected_type"));
358 };
359 let Value::Map(u) = u else {
360 return Err(Problem::structure("unprotected_type"));
361 };
362 if !matches!(m, Value::Bytes(_) | Value::Null) {
363 return Err(Problem::structure("payload_type"));
364 }
365 let Value::Bytes(s) = s else {
366 return Err(Problem::structure("signature_type"));
367 };
368 report.protected_bytes = Some(p.clone());
369 report.signature_bytes = Some(s.clone());
370 if let Value::Bytes(m) = m {
371 report.payload_bytes = Some(m.clone());
372 }
373 if p.is_empty() {
374 return Err(Problem::structure("protected_not_map"));
375 }
376 budget.scan(p, 2, Role::ProtectedMap, "protected_trailing_data")?;
378 let protected = decode(p)?;
379 check_unique_maps(&protected).map_err(Problem::structure)?;
380 let Value::Map(p) = &protected else {
381 return Err(Problem::structure("protected_not_map"));
382 };
383 labels(p)?;
384 labels(u)?;
385 if get(p, 15).is_some() && get(u, 15).is_some() {
386 return Err(Problem::structure("label15_single_occurrence"));
387 }
388 if get(u, 2).is_some() {
389 return Err(Problem::structure("crit_location"));
390 }
391 if get(p, 396).is_some() {
392 return Err(Problem::structure("vdp_location"));
393 }
394 let overlaps = p
395 .iter()
396 .any(|(key, _)| u.iter().any(|(other, _)| key == other));
397 if report.policy.strict_cross_map && overlaps {
398 apply_problem(report, Problem::policy("cross_map_overlap"));
399 }
400 report.effective_headers = p.clone();
402 report.effective_headers.extend(
403 u.iter()
404 .filter(|(key, _)| !p.iter().any(|(other, _)| key == other))
405 .cloned(),
406 );
407 let mut critical_unknown = false;
408 if let Some(crit) = get(p, 2) {
409 let Value::Array(crit) = crit else {
410 return Err(Problem::structure("crit_type"));
411 };
412 if crit.is_empty() {
413 return Err(Problem::structure("crit_empty"));
414 }
415 for (i, label) in crit.iter().enumerate() {
416 if !matches!(label, Value::Integer(_) | Value::Text(_)) {
417 return Err(Problem::structure("crit_label_type"));
418 }
419 if crit[..i].contains(label) {
420 return Err(Problem::structure("crit_duplicate_label"));
421 }
422 if integer(label) == Some(2) {
423 return Err(Problem::structure("crit_self_reference"));
424 }
425 if !p.iter().any(|(key, _)| key == label) {
426 return Err(Problem::structure("crit_reference_absent"));
427 }
428 if !integer(label)
429 .is_some_and(|n| InspectionPolicy::UNDERSTOOD_CRITICAL_LABELS.contains(&n))
430 {
431 critical_unknown = true;
432 }
433 }
434 }
435 let vdp = get(u, 396).ok_or(Problem::structure("vdp_location"))?;
438 let Value::Map(vdp) = vdp else {
439 return Err(Problem::structure("vdp_map_type"));
440 };
441 labels(vdp)?;
442 let alg = get(p, 1).and_then(integer);
443 let vds = get(p, 395).and_then(integer);
444 let mut proof_unknown = false;
445 if vds == Some(1) {
446 if let Some(proofs) = get(vdp, -1) {
447 let Value::Array(proofs) = proofs else {
448 return Err(Problem::structure("inclusion_array_type"));
449 };
450 if proofs.is_empty() {
451 return Err(Problem::structure("empty_inclusion_array"));
452 }
453 if proofs.len() > budget.limits.max_proofs_per_receipt {
454 return Err(Problem::policy("proof_limit"));
455 }
456 for proof in proofs {
457 let Value::Bytes(bytes) = proof else {
458 return Err(Problem::structure("proof_not_bstr"));
459 };
460 budget.scan(bytes, 5, Role::Proof, "proof_trailing_data")?;
462 let proof = decode(bytes)?;
463 check_unique_maps(&proof).map_err(Problem::structure)?;
464 inspect_proof(&proof, budget.limits)?;
465 }
466 } else if vdp.is_empty() {
467 return Err(Problem::structure("missing_inclusion"));
468 }
469 proof_unknown = vdp.iter().any(|(key, _)| integer(key) != Some(-1));
471 }
472 report.structure = InspectionFinding::passed();
473 let raw_x5t_invalid = budget.unprotected_x5t_invalid && get(p, 34).is_none();
474 let claims_result = if raw_x5t_invalid {
475 Err(Problem::claims("x5t_shape"))
476 } else {
477 inspect_claims(p, u, budget.limits)
478 };
479 match claims_result {
480 Ok(claims) => {
481 report.unauthenticated_claims = Some(claims);
482 report.required_claims = InspectionFinding::passed();
483 }
484 Err(problem) => apply_problem(report, problem),
485 }
486 if alg.is_none() || vds.is_none() {
489 return Ok(());
490 }
491 let unsupported = if vds == Some(0) {
492 report.support = InspectionFinding::new(
493 InspectionStatus::Failed,
494 "reserved_vds",
495 "VDS zero is reserved, not an unsupported registration.",
496 );
497 return Ok(());
498 } else if vds == Some(2) {
499 Some("ccf_profile_not_implemented")
500 } else if vds != Some(1) {
501 Some("unknown_vds_registry_review")
502 } else if alg != Some(-8) {
503 Some("ts_algorithm")
504 } else if critical_unknown {
505 Some("critical_semantics")
506 } else if proof_unknown {
507 Some("proof_type_registry_review")
508 } else if get(p, 33).is_some() || get(p, 34).is_some() {
509 if raw_x5t_invalid || x509_shape(p, u, budget.limits).is_err() {
511 return Ok(());
512 }
513 Some("x509_not_implemented")
514 } else {
515 None
516 };
517 report.support = match unsupported {
518 Some(code) => InspectionFinding::new(
519 InspectionStatus::Unsupported,
520 code,
521 "Not implemented by the initial Ed25519/RFC9162 policy; no crypto or trust finding follows.",
522 ),
523 None => InspectionFinding::passed(),
524 };
525 Ok(())
526}
527
528fn inspect_proof(value: &Value, limits: &InspectionLimits) -> Result<(), Problem> {
529 let Value::Array(items) = value else {
530 return Err(Problem::structure("proof_array_type"));
531 };
532 let [tree, leaf, path] = items.as_slice() else {
533 return Err(Problem::structure("proof_element_count"));
534 };
535 let tree: u64 = integer(tree)
536 .and_then(|n| n.try_into().ok())
537 .ok_or(Problem::structure("tree_type"))?;
538 let leaf: u64 = integer(leaf)
539 .and_then(|n| n.try_into().ok())
540 .ok_or(Problem::structure("leaf_type"))?;
541 if tree == 0 {
542 return Err(Problem::structure("tree_bounds"));
543 }
544 if tree > limits.max_tree_size {
545 return Err(Problem::policy("tree_size_limit"));
546 }
547 if leaf >= tree {
548 return Err(Problem::structure("leaf_bounds"));
549 }
550 let Value::Array(path) = path else {
551 return Err(Problem::structure("path_type"));
552 };
553 if path.is_empty() {
554 return Err(Problem::structure("empty_path"));
555 }
556 for node in path {
557 let Value::Bytes(node) = node else {
558 return Err(Problem::structure("path_node_type"));
559 };
560 if node.len() != 32 {
561 return Err(Problem::structure("path_node_length"));
562 }
563 }
564 Ok(())
565}
566fn uri(text: &str) -> bool {
567 fluent_uri::Uri::parse(text).is_ok()
568}
569fn x509_shape(
570 p: &[(Value, Value)],
571 u: &[(Value, Value)],
572 limits: &InspectionLimits,
573) -> Result<(), Problem> {
574 if let Some(x5t) = get(p, 34).or_else(|| get(u, 34))
575 && !matches!(x5t, Value::Array(a) if matches!(a.as_slice(), [Value::Integer(_) | Value::Text(_), Value::Bytes(_)]))
576 {
577 return Err(Problem::claims("x5t_shape"));
578 }
579 if let Some(chain) = get(p, 33).or_else(|| get(u, 33)) {
580 let certs: &[Value] = match chain {
581 Value::Bytes(_) => core::slice::from_ref(chain),
582 Value::Array(certs) if certs.len() >= 2 => certs,
583 _ => return Err(Problem::claims("x5chain_shape")),
584 };
585 if certs.len() > limits.max_certificate_chain_length {
586 return Err(Problem::policy("certificate_count_limit"));
587 }
588 for cert in certs {
589 let Value::Bytes(cert) = cert else {
590 return Err(Problem::claims("x5chain_shape"));
591 };
592 if cert.len() > limits.max_certificate_bytes {
593 return Err(Problem::policy("certificate_byte_limit"));
594 }
595 }
596 }
597 Ok(())
598}
599fn inspect_claims(
600 p: &[(Value, Value)],
601 u: &[(Value, Value)],
602 limits: &InspectionLimits,
603) -> Result<UnauthenticatedReceiptClaims, Problem> {
604 for key in [1, 395] {
605 let value = get(p, key).ok_or(Problem::claims(if get(u, key).is_some() {
606 "required_header_location"
607 } else {
608 "missing_required_header"
609 }))?;
610 if integer(value).is_none() {
611 return Err(Problem::claims("required_header_type"));
612 }
613 }
614 let claims = get(p, 15).ok_or(Problem::claims(if get(u, 15).is_some() {
615 "claims_location"
616 } else {
617 "missing_claims"
618 }))?;
619 let Value::Map(claims) = claims else {
620 return Err(Problem::claims("claims_map_type"));
621 };
622 if claims
623 .iter()
624 .any(|(key, _)| !matches!(key, Value::Integer(_) | Value::Text(_)))
625 {
626 return Err(Problem::claims("claim_label_type"));
627 }
628 let text = |label| -> Result<&str, Problem> {
629 let value = get(claims, label).ok_or(Problem::claims(
630 if get(claims, 1).is_none() && get(claims, 2).is_none() {
631 "missing_integer_claims"
632 } else {
633 "missing_claim"
634 },
635 ))?;
636 let Value::Text(text) = value else {
637 return Err(Problem::claims("claim_value_type"));
638 };
639 Ok(text)
640 };
641 let iss = text(1)?;
642 let sub = text(2)?;
643 let x509 = get(p, 33).is_some() || get(p, 34).is_some();
644 if x509 && (iss.is_empty() || iss.chars().count() > 8192) {
645 return Err(Problem::claims("issuer_length"));
646 }
647 for value in [iss, sub] {
648 if value.chars().count() > limits.max_claim_text_characters {
649 return Err(Problem::policy("claim_text_limit"));
650 }
651 }
652 if x509 && !uri(iss) {
653 return Err(Problem::claims("x509_issuer_uri"));
654 }
655 for value in [iss, sub] {
656 if value.contains(':') && !uri(value) {
657 return Err(Problem::claims("uri_syntax"));
658 }
659 }
660 x509_shape(p, u, limits)?;
661 if let Some(kid) = get(p, 4).or_else(|| get(u, 4))
663 && !matches!(kid, Value::Bytes(_))
664 {
665 return Err(Problem::claims("kid_type"));
666 }
667 if !x509 && get(p, 4).is_none() {
668 return Err(if get(u, 4).is_some() {
669 Problem::policy("protected_kid_required")
670 } else {
671 Problem::claims("missing_key_identifier")
672 });
673 }
674 Ok(UnauthenticatedReceiptClaims {
675 issuer: iss.into(),
676 subject: sub.into(),
677 authenticated: false,
678 })
679}