1use alloc::{vec, vec::Vec};
16use coset::cbor::Value;
17use ed25519_dalek::{Signature, VerifyingKey};
18use sha2::{Digest, Sha256};
19
20use crate::receipt_cbor::{Budget, Role};
21use crate::{
22 EnvelopeReport, InspectionFinding as Finding, InspectionLimits, InspectionPolicy,
23 InspectionStatus as Status, Receipt, derive_candidate_entry, inspect_scitt_receipt, leaf_hash,
24};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum TrustInputOrigin {
29 LocalSimulation,
31 CallerAuthenticatedExternal,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum BindingProvenance<'a> {
39 Missing,
40 Unauthenticated {
42 origin: &'a str,
43 },
44 CallerAuthenticated {
45 authority: &'a str,
46 evidence_ref: &'a str,
47 },
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum TsPublicKey<'a> {
53 Ed25519([u8; 32]),
54 Unsupported {
56 key_type: &'a str,
57 bytes: &'a [u8],
58 },
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct TsKeyAssociation<'a> {
64 pub service_identity: &'a str,
65 pub public_key: TsPublicKey<'a>,
66 pub algorithm: i64,
67 pub provenance: BindingProvenance<'a>,
68 pub kid_hint: Option<&'a [u8]>,
70 pub valid_from: Option<i64>,
72 pub valid_until: Option<i64>,
73 pub explicitly_distrusted: bool,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum RotationPolicy {
81 ValidAtEvaluationTimeV1,
82}
83
84#[derive(Debug)]
86pub struct TsTrustContext<'a> {
87 pub accepted_ts_identities: &'a [&'a str],
88 pub associations: &'a [TsKeyAssociation<'a>],
89 pub evaluation_time: Option<i64>,
90 pub rotation_policy: Option<RotationPolicy>,
91 pub provisioned_by: Option<&'a str>,
92 pub origin: TrustInputOrigin,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct VerificationLimits {
99 pub max_statement_bytes: usize,
100 pub max_statement_cbor_items: usize,
101 pub max_statement_cbor_nesting: usize,
102 pub max_statement_map_entries: usize,
103 pub max_associations: usize,
104 pub max_accepted_identities: usize,
105 pub max_candidate_keys: usize,
106 pub max_signature_attempts: usize,
107 pub max_trust_field_bytes: usize,
108}
109impl Default for VerificationLimits {
110 fn default() -> Self {
111 Self {
112 max_statement_bytes: 1_048_576,
113 max_statement_cbor_items: 4_096,
114 max_statement_cbor_nesting: 16,
115 max_statement_map_entries: 64,
116 max_associations: 64,
117 max_accepted_identities: 64,
118 max_candidate_keys: 16,
119 max_signature_attempts: 256,
120 max_trust_field_bytes: 8_192,
121 }
122 }
123}
124impl VerificationLimits {
125 fn valid(&self) -> bool {
126 let d = Self::default();
127 macro_rules! bounded { ($($f:ident),+) => { true $(&& self.$f > 0 && self.$f <= d.$f)+ }; }
128 bounded!(
129 max_statement_bytes,
130 max_statement_cbor_items,
131 max_statement_cbor_nesting,
132 max_statement_map_entries,
133 max_associations,
134 max_accepted_identities,
135 max_candidate_keys,
136 max_signature_attempts,
137 max_trust_field_bytes
138 )
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Default)]
146pub struct ReceiptVerificationPolicy {
147 pub envelope: InspectionPolicy,
148 pub limits: VerificationLimits,
149}
150impl ReceiptVerificationPolicy {
151 pub const ID: &'static str = "pask71-offline-service-association/1";
152}
153
154#[derive(Debug, Clone)]
156pub struct VerifyingKeyEvidence {
157 pub public_key: [u8; 32],
158 pub sha256: [u8; 32],
159 pub association_indices: Vec<usize>,
160 pub kid_hint_match_indices: Vec<usize>,
161}
162
163#[derive(Debug, Clone)]
165pub struct ProofVerification {
166 pub proof_index: usize,
167 pub reconstructed_root: Option<[u8; 32]>,
168 pub root_reconstruction: Finding,
169 pub attached_root_equality: Finding,
170 pub ts_signature: Finding,
171 pub inclusion: Finding,
172 pub verifying_key_indices: Vec<usize>,
174}
175
176#[derive(Debug)]
178pub struct ReceiptVerificationReport<'a> {
179 pub statement_bytes: &'a [u8],
180 pub envelope: EnvelopeReport<'a>,
181 pub trust_context: &'a TsTrustContext<'a>,
182 pub policy: ReceiptVerificationPolicy,
183 pub policy_id: &'static str,
184 pub candidate_entry: Option<Vec<u8>>,
185 pub candidate_derivation: Finding,
186 pub selected_policy: Finding,
187 pub candidate_keys: Vec<VerifyingKeyEvidence>,
188 pub key_configuration: Vec<Finding>,
190 pub proofs: Vec<ProofVerification>,
191 pub signature_attempts: usize,
192 pub ts_signature: Finding,
193 pub inclusion: Finding,
194 pub ts_key_association: Finding,
195 pub ts_identity_trust: Finding,
196 pub subject_policy: Finding,
197 pub issuer_signature: Finding,
198 pub application_policy: Finding,
199 pub hardware_appraisal: Finding,
200 pub acceptable_for_registration: Finding,
201}
202
203fn finding(status: Status, code: &'static str, detail: &'static str) -> Finding {
204 Finding {
205 status,
206 code,
207 detail,
208 evidence_refs: vec![
209 "encoded_receipt",
210 "statement_bytes",
211 "trust_context",
212 "policy",
213 ],
214 }
215}
216fn skipped() -> Finding {
217 finding(
218 Status::NotEvaluated,
219 "prerequisite_not_established",
220 "A dependent check was not run.",
221 )
222}
223fn unestablished(code: &'static str) -> Finding {
224 finding(
225 Status::Unestablished,
226 code,
227 "Required independent evidence is absent; not acceptance.",
228 )
229}
230fn failed(code: &'static str) -> Finding {
231 finding(Status::Failed, code, "Failed only the named dimension.")
232}
233fn passed(code: &'static str) -> Finding {
234 finding(
235 Status::Passed,
236 code,
237 "Passed only the named dimension under the explicit supplied inputs.",
238 )
239}
240
241#[must_use]
247pub fn verify_scitt_receipt<'a>(
248 receipt_bytes: &'a [u8],
249 statement_bytes: &'a [u8],
250 trust_context: &'a TsTrustContext<'a>,
251 policy: &ReceiptVerificationPolicy,
252) -> ReceiptVerificationReport<'a> {
253 let mut r = ReceiptVerificationReport {
254 statement_bytes,
255 envelope: inspect_scitt_receipt(receipt_bytes, &policy.envelope),
256 trust_context,
257 policy: policy.clone(),
258 policy_id: ReceiptVerificationPolicy::ID,
259 candidate_entry: None,
260 candidate_derivation: skipped(),
261 selected_policy: passed("phase2_policy_selected"),
262 candidate_keys: Vec::new(),
263 key_configuration: Vec::new(),
264 proofs: Vec::new(),
265 signature_attempts: 0,
266 ts_signature: skipped(),
267 inclusion: skipped(),
268 ts_key_association: unestablished("verifying_key_not_established"),
269 ts_identity_trust: unestablished("trust_not_established"),
270 subject_policy: unestablished("phase3_subject_semantics_not_implemented"),
271 issuer_signature: finding(
272 Status::NotEvaluated,
273 "outside_phase2",
274 "Statement issuer verification is not implemented by this API.",
275 ),
276 application_policy: unestablished("phase3_application_not_implemented"),
277 hardware_appraisal: finding(
278 Status::NotEvaluated,
279 "outside_phase2",
280 "No hardware evidence is evaluated.",
281 ),
282 acceptable_for_registration: unestablished("registration_prerequisites_not_established"),
283 };
284 run(&mut r);
285 r
286}
287
288fn run(r: &mut ReceiptVerificationReport<'_>) {
289 let e = &r.envelope;
290 if [
291 &e.structure,
292 &e.required_claims,
293 &e.support,
294 &e.selected_policy,
295 ]
296 .iter()
297 .any(|f| f.status != Status::Passed)
298 {
299 return;
300 }
301 if !r.policy.limits.valid() {
302 r.selected_policy = failed("invalid_phase2_limits");
303 return;
304 }
305 let l = &r.policy.limits;
306 if r.statement_bytes.len() > l.max_statement_bytes {
307 r.candidate_derivation = failed("statement_byte_limit");
308 return;
309 }
310 let limits = InspectionLimits {
313 max_receipt_bytes: l.max_statement_bytes,
314 max_cbor_items: l.max_statement_cbor_items,
315 max_cbor_nesting: l.max_statement_cbor_nesting,
316 max_map_entries: l.max_statement_map_entries,
317 ..InspectionLimits::default()
318 };
319 let mut budget = Budget {
320 limits: &limits,
321 items: 0,
322 unprotected_x5t_invalid: false,
323 };
324 if budget
325 .scan(r.statement_bytes, 0, Role::Any, "statement_trailing_data")
326 .is_err()
327 {
328 r.candidate_derivation = failed("statement_cbor_preflight");
329 return;
330 }
331 let Ok(candidate) = derive_candidate_entry(r.statement_bytes) else {
332 r.candidate_derivation = failed("statement_candidate_shape");
333 return;
334 };
335 r.candidate_derivation = passed("exact_statement_candidate_derived_not_outer_validation");
336 r.candidate_entry = Some(candidate);
337 if !bounded_context(r.trust_context, l) {
338 r.selected_policy = failed("trust_context_limit");
339 return;
340 }
341 let Ok(receipt) = Receipt::from_cose_sign1(r.envelope.encoded_receipt) else {
342 r.selected_policy = failed("inspection_parser_disagreement");
343 return;
344 };
345 let kid = r.envelope.effective_headers.iter().find_map(|(k, v)| {
346 if *k == Value::Integer(4.into())
347 && let Value::Bytes(b) = v
348 {
349 return Some(b.as_slice());
350 }
351 None
352 });
353 for (i, a) in r.trust_context.associations.iter().enumerate() {
354 let TsPublicKey::Ed25519(key) = a.public_key else {
355 r.key_configuration.push(finding(
356 Status::Unsupported,
357 "key_type_not_supported",
358 "This row is not used for signature attempts; no key-type coercion.",
359 ));
360 continue;
361 };
362 if a.algorithm != -8 {
363 r.key_configuration
364 .push(failed("configured_algorithm_key_mismatch"));
365 continue;
366 }
367 r.key_configuration
368 .push(passed("configured_ed25519_algorithm_key_agreement"));
369 let position = r.candidate_keys.iter().position(|k| k.public_key == key);
370 let index = match position {
371 Some(index) => index,
372 None => {
373 if r.candidate_keys.len() == l.max_candidate_keys {
374 r.selected_policy = failed("candidate_key_limit");
375 return;
376 }
377 r.candidate_keys.push(VerifyingKeyEvidence {
378 public_key: key,
379 sha256: Sha256::digest(key).into(),
380 association_indices: Vec::new(),
381 kid_hint_match_indices: Vec::new(),
382 });
383 r.candidate_keys.len() - 1
384 }
385 };
386 r.candidate_keys[index].association_indices.push(i);
387 if kid.is_some() && kid == a.kid_hint {
388 r.candidate_keys[index].kid_hint_match_indices.push(i);
389 }
390 }
391 if r.candidate_keys
392 .len()
393 .checked_mul(receipt.inclusion_proofs.len())
394 .is_none_or(|n| n > l.max_signature_attempts)
395 {
396 r.selected_policy = failed("signature_attempt_limit");
397 return;
398 }
399 let leaf = leaf_hash(r.candidate_entry.as_deref().unwrap_or_default());
400 let signature =
401 Signature::from_slice(r.envelope.signature_bytes.as_deref().unwrap_or_default());
402 for (i, proof) in receipt.inclusion_proofs.iter().enumerate() {
403 let mut p = ProofVerification {
404 proof_index: i,
405 reconstructed_root: None,
406 root_reconstruction: skipped(),
407 attached_root_equality: skipped(),
408 ts_signature: skipped(),
409 inclusion: unestablished("root_not_authenticated"),
410 verifying_key_indices: Vec::new(),
411 };
412 if let Ok(root) = proof.reconstruct_root(leaf) {
413 p.reconstructed_root = Some(root);
414 p.root_reconstruction = passed("root_reconstructed_not_authenticated");
415 if receipt
416 .payload
417 .as_ref()
418 .is_some_and(|b| b.as_slice() != root)
419 {
420 p.attached_root_equality = failed("attached_root_mismatch");
421 p.inclusion = failed("attached_root_mismatch");
422 } else {
423 p.attached_root_equality = if receipt.payload.is_some() {
424 passed("attached_root_equal_not_authenticated")
425 } else {
426 finding(
427 Status::NotEvaluated,
428 "detached_payload",
429 "No attached root; signature must authenticate the reconstructed root.",
430 )
431 };
432 if let Ok(signature) = &signature {
433 let structure = Value::Array(vec![
435 Value::Text("Signature1".into()),
436 Value::Bytes(r.envelope.protected_bytes.clone().unwrap_or_default()),
437 Value::Bytes(Vec::new()),
438 Value::Bytes(root.to_vec()),
439 ]);
440 let mut signed = Vec::new();
441 if coset::cbor::ser::into_writer(&structure, &mut signed).is_err() {
442 r.selected_policy = failed("sig_structure_encoding");
443 return;
444 }
445 for (ki, key) in r.candidate_keys.iter().enumerate() {
446 r.signature_attempts += 1;
447 if VerifyingKey::from_bytes(&key.public_key)
448 .is_ok_and(|k| k.verify_strict(&signed, signature).is_ok())
449 {
450 p.verifying_key_indices.push(ki);
451 }
452 }
453 if p.verifying_key_indices.is_empty() {
454 p.ts_signature = if r.candidate_keys.is_empty() {
455 unestablished("no_algorithm_compatible_candidate_key")
456 } else {
457 failed("signature_did_not_verify")
458 };
459 } else {
460 p.ts_signature = passed("signature_under_actual_supplied_key");
461 p.inclusion = passed("candidate_included_in_signature_authenticated_root");
462 }
463 } else {
464 p.ts_signature = failed("invalid_ed25519_signature_length");
465 }
466 }
467 } else {
468 p.root_reconstruction = failed("invalid_merkle_path");
469 p.inclusion = failed("invalid_merkle_path");
470 }
471 r.proofs.push(p);
472 }
473 r.ts_signature = aggregate(
474 r.proofs.iter().map(|p| &p.ts_signature),
475 "all_proof_roots_signed",
476 );
477 r.inclusion = aggregate(
478 r.proofs.iter().map(|p| &p.inclusion),
479 "all_proofs_authenticated",
480 );
481 if r.ts_signature.status != Status::Passed || r.inclusion.status != Status::Passed {
482 return;
483 }
484 let first = &r.proofs[0].verifying_key_indices;
486 if first.len() != 1 || r.proofs.iter().any(|p| p.verifying_key_indices != *first) {
487 r.ts_key_association = failed("ambiguous_actual_verifying_key");
488 r.ts_identity_trust = failed("ambiguous_actual_verifying_key");
489 return;
490 }
491 associate(r, first[0]);
492}
493
494fn aggregate<'a>(items: impl Iterator<Item = &'a Finding>, code: &'static str) -> Finding {
495 let statuses: Vec<Status> = items.map(|f| f.status).collect();
496 if statuses.contains(&Status::Failed) {
497 return failed(code);
498 }
499 if statuses.iter().all(|s| *s == Status::Passed) && !statuses.is_empty() {
500 return passed(code);
501 }
502 if statuses.iter().all(|s| *s == Status::NotEvaluated) {
503 return skipped();
504 }
505 unestablished(code)
506}
507
508fn bounded_context(c: &TsTrustContext<'_>, l: &VerificationLimits) -> bool {
509 if c.associations.len() > l.max_associations
510 || c.accepted_ts_identities.len() > l.max_accepted_identities
511 {
512 return false;
513 }
514 let bounded = |s: &str| s.len() <= l.max_trust_field_bytes;
515 if !c.accepted_ts_identities.iter().all(|s| bounded(s))
516 || c.provisioned_by.is_some_and(|s| !bounded(s))
517 {
518 return false;
519 }
520 c.associations.iter().all(|a| {
521 bounded(a.service_identity)
522 && a.kid_hint
523 .is_none_or(|b| b.len() <= l.max_trust_field_bytes)
524 && match a.public_key {
525 TsPublicKey::Ed25519(_) => true,
526 TsPublicKey::Unsupported { key_type, bytes } => {
527 bounded(key_type) && bytes.len() <= l.max_trust_field_bytes
528 }
529 }
530 && match a.provenance {
531 BindingProvenance::Missing => true,
532 BindingProvenance::Unauthenticated { origin } => bounded(origin),
533 BindingProvenance::CallerAuthenticated {
534 authority,
535 evidence_ref,
536 } => bounded(authority) && bounded(evidence_ref),
537 }
538 })
539}
540
541fn associate(r: &mut ReceiptVerificationReport<'_>, key_index: usize) {
542 let key = r.candidate_keys[key_index].public_key;
543 let c = r.trust_context;
544 let issuer = r
545 .envelope
546 .unauthenticated_claims
547 .as_ref()
548 .map(|v| v.issuer.as_str())
549 .unwrap_or("");
550 if c.associations
553 .iter()
554 .any(|a| a.public_key == TsPublicKey::Ed25519(key) && a.explicitly_distrusted)
555 {
556 r.ts_key_association = failed("actual_key_explicitly_distrusted");
557 r.ts_identity_trust = failed("actual_key_explicitly_distrusted");
558 return;
559 }
560 let matching: Vec<_> = c
561 .associations
562 .iter()
563 .filter(|a| {
564 a.public_key == TsPublicKey::Ed25519(key)
565 && a.algorithm == -8
566 && a.service_identity == issuer
567 })
568 .collect();
569 if matching.is_empty() {
570 r.ts_key_association = failed("actual_key_not_associated_with_claimed_service");
571 r.ts_identity_trust = failed("unauthorized_verifying_key");
572 return;
573 }
574 let authenticated: Vec<_> = matching
575 .iter()
576 .filter(|a| {
577 matches!(
578 a.provenance, BindingProvenance::CallerAuthenticated { authority, evidence_ref }
579 if !authority.is_empty() && !evidence_ref.is_empty()
580 )
581 })
582 .collect();
583 if authenticated.is_empty() || c.provisioned_by.is_none_or(str::is_empty) {
584 r.ts_key_association = unestablished("authenticated_binding_evidence_missing");
585 return;
586 }
587 r.ts_key_association = passed("actual_key_has_caller_authenticated_service_binding");
588 if !c.accepted_ts_identities.contains(&issuer) {
589 r.ts_identity_trust = failed("service_identity_not_accepted");
590 return;
591 }
592 let (Some(now), Some(RotationPolicy::ValidAtEvaluationTimeV1)) =
593 (c.evaluation_time, c.rotation_policy)
594 else {
595 r.ts_identity_trust = unestablished("evaluation_time_or_rotation_policy_missing");
596 return;
597 };
598 let mut missing_window = false;
599 let valid = authenticated
600 .iter()
601 .any(|a| match (a.valid_from, a.valid_until) {
602 (Some(from), Some(until)) => from < until && from <= now && now < until,
603 _ => {
604 missing_window = true;
605 false
606 }
607 });
608 if !valid {
609 r.ts_identity_trust = if missing_window {
610 unestablished("key_validity_evidence_missing")
611 } else {
612 failed("no_current_authorized_key_window")
613 };
614 return;
615 }
616 match c.origin {
617 TrustInputOrigin::LocalSimulation => {
618 r.ts_identity_trust = passed("local_simulation_conditional_trust_only");
619 r.acceptable_for_registration =
620 unestablished("simulation_is_not_real_registration_evidence");
621 }
622 TrustInputOrigin::CallerAuthenticatedExternal => {
623 r.ts_identity_trust =
624 passed("trusted_under_caller_authenticated_offline_configuration");
625 r.acceptable_for_registration =
626 passed("single_receipt_registration_evidence_only_not_application_acceptance");
627 }
628 }
629}