Skip to main content

pask_wire/
receipt_inspection.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Wilder Management Inc. (d/b/a Wilder Robotics) <rob@wilder-robotics.com>
3// See LICENSING.md in the workspace root.
4
5//! Key-free, offline Phase 1 inspection, not signature/inclusion/trust verification.
6//! RFC 9942/9943 envelope and text-claim checks are separate from local support
7//! and policy. This module does not coordinate attachments or change the #70 reader.
8use alloc::{string::String, vec::Vec};
9use coset::cbor::Value;
10use serde::Serialize;
11
12use crate::receipt_cbor::{Budget, Role};
13
14/// Finding vocabulary shared with the proposed recipient report.
15#[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/// One independently scoped finding. Codes are machine-readable, not acceptance badges.
26#[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/// Finite local resource ceilings, not universal RFC limits. Only Phase 1 budgets
66/// live here: attachment/statement/key-attempt limits belong to later APIs.
67#[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/// Selected local policy. Support, protected kid and empty external AAD are fixed
102/// to the accepted initial profile; callers cannot declare new supported semantics.
103/// Limits may be tightened, never raised above the default hard ceilings.
104#[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/// Receipt-controlled text, explicitly unauthenticated. Subject TYPE is checked;
135/// subject semantic correspondence is not. No URI normalization is performed.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
137pub struct UnauthenticatedReceiptClaims {
138    pub issuer: String,
139    pub subject: String,
140    pub authenticated: bool,
141}
142
143/// Exact original input is borrowed even on oversized/malformed inputs (no copy).
144/// Protected/payload/signature contents are exact, never re-encoded for signing.
145/// Effective headers are decoded conveniences, NOT replacement signed bytes.
146/// No field asserts registration, application acceptance, crypto or TS identity.
147#[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/// Inspect a single encoded tagged Receipt under explicit local policy, with no
202/// keys, I/O, network, crypto, subject binding or attachment acceptance decision.
203/// A passed structure/claims finding does NOT establish a valid signature.
204#[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
267/// Called before any semantic lookup, including generic receipt parsing.
268/// All nested maps are visited; integer equality is decoded-value equality,
269/// so alternate-width integer encodings cannot bypass duplicate rejection.
270pub(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    // The protected map is at container depth 3 (tag=1, Sign1 array=2).
377    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    // Only otherwise permitted overlaps reach this point; protected wins.
401    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    // Validate profile-independent VDP container before profile dispatch. Opaque
436    // unknown-profile proof bytes are never reinterpreted as RFC9162 CBOR.
437    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                // Embedded proof array is depth 6, path array depth 7.
461                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        // Unknown extra proof types also remain visible as unsupported.
470        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    // Support is independent of claim validity, but only typed identifiers can
487    // establish algorithm/profile support. Invalid shapes are never unsupported.
488    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        // Only valid X.509 syntax earns an unsupported-support finding.
510        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    // Any supplied kid must have the right shape, even alongside an alternative.
662    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}