Skip to main content

pask_wire/
payload.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// pask-wire is licensed Apache-2.0. No commercial agreement is required to use,
4// modify or redistribute it; see LICENSING.md in the workspace root.
5
6use alloc::{
7    borrow::ToOwned,
8    string::{String, ToString},
9    vec::Vec,
10};
11use serde::{Deserialize, Serialize};
12use time::{OffsetDateTime, format_description::well_known::Rfc3339};
13
14use crate::{Error, Result, sha256_prefixed, validate_sha256};
15
16/// Supported PSER profile version.
17pub const SPEC_VERSION: &str = "wilder.pser/0.5";
18
19/// Profile version carrying the timestamp containment requirement.
20pub const SPEC_VERSION_06: &str = "wilder.pser/0.6";
21
22/// Returns true when the given spec string is a supported profile version.
23#[must_use]
24pub fn is_supported_spec(spec: &str) -> bool {
25    spec == SPEC_VERSION || spec == SPEC_VERSION_06
26}
27
28/// Wire strings for the three named `adapter.ackProvenance` values.
29///
30/// The value space is a closed set enumerated in the profile document rather
31/// than an IANA registry, so these strings are the whole of it. A conforming
32/// producer MUST emit one of them.
33const BINDING_MODE_DIRECT_WITNESS: &str = "DIRECT_WITNESS";
34const BINDING_MODE_DELEGATED_WITNESS: &str = "DELEGATED_WITNESS";
35
36const ACK_PROVENANCE_THIRD_PARTY: &str = "THIRD_PARTY";
37const ACK_PROVENANCE_ISSUER_ASSERTED: &str = "ISSUER_ASSERTED";
38const ACK_PROVENANCE_NONE: &str = "NONE";
39
40/// Wire strings for the three named `issuerAffiliation` values.
41///
42/// Closed set, enumerated in the profile document rather than an IANA registry.
43/// A conforming producer MUST emit one of them.
44const ISSUER_AFFILIATION_AFFILIATED: &str = "AFFILIATED";
45const ISSUER_AFFILIATION_INDEPENDENT: &str = "INDEPENDENT";
46const ISSUER_AFFILIATION_NOT_DISCLOSED: &str = "NOT_DISCLOSED";
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50struct Site {
51    id: String,
52    class: SiteClass,
53    envelope: SiteEnvelope,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58enum SiteClass {
59    Residential,
60    Industrial,
61    Healthcare,
62    Infra,
63    Other,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68struct SiteEnvelope {
69    id: String,
70    digest: String,
71    #[serde(deserialize_with = "deserialize_required_option")]
72    geobounds: Option<String>,
73    temporal: Temporal,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78struct Temporal {
79    #[serde(deserialize_with = "deserialize_required_option")]
80    starts: Option<String>,
81    #[serde(deserialize_with = "deserialize_required_option")]
82    ends: Option<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87struct Actor {
88    id: String,
89    class: ActorClass,
90    operator: String,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
95enum ActorClass {
96    Autonomous,
97    SemiAutonomous,
98    Human,
99    Crew,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields, rename_all = "camelCase")]
104struct Engagement {
105    id: String,
106    window: Window,
107    r#type: String,
108    outcome_class: OutcomeClass,
109    envelope_conformance: EnvelopeConformance,
110    evidence_digest: String,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115struct Window {
116    start: String,
117    end: String,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
122enum OutcomeClass {
123    Completed,
124    Aborted,
125    Refused,
126    Errored,
127    ObservedOnly,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
132enum EnvelopeConformance {
133    Within,
134    ExceededTemporal,
135    ExceededGeo,
136    ExceededActor,
137    Unknown,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields, rename_all = "camelCase")]
142struct Attestation {
143    binding_mode: BindingMode,
144    tee_class: String,
145    measured_boot: MeasuredBoot,
146    platform_evidence: PlatformEvidence,
147    sealed_evidence: SealedEvidence,
148    witness_key: String,
149    validity: Validity,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields, rename_all = "camelCase")]
154struct MeasuredBoot {
155    chain: String,
156    components: Vec<MeasuredBootComponent>,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(deny_unknown_fields, rename_all = "camelCase")]
161struct MeasuredBootComponent {
162    name: String,
163    digest: String,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields, rename_all = "camelCase")]
168struct PlatformEvidence {
169    encoding: String,
170    digest: String,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(deny_unknown_fields, rename_all = "camelCase")]
175struct Validity {
176    not_before: String,
177    not_after: String,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields, rename_all = "camelCase")]
182struct SealedEvidence {
183    digest: String,
184    size_bytes: u64,
185    encoding: String,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields, rename_all = "camelCase")]
190struct Adapter {
191    system: String,
192    endpoint: String,
193    posted_at: String,
194    ack_digest: String,
195    ack_provenance: AckProvenance,
196    mode: AdapterMode,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200enum AdapterMode {
201    #[serde(rename = "WRITE_ONLY")]
202    WriteOnly,
203}
204
205/// How the acknowledgement recorded in `adapter.ackDigest` was obtained.
206///
207/// # Why this member exists
208///
209/// Under a write-only adapter posture there is no read path, so `ackDigest`
210/// alone cannot tell a reader whether an independent operations layer
211/// acknowledged the write-in or the Issuer authored a minimal acknowledgement
212/// object itself when nothing structured came back. The digest proves a digest
213/// was computed over something. It does not prove who produced the thing. This
214/// member makes the distinction a property of the record instead of a property
215/// of the Issuer's unpublished manifest.
216///
217/// Classification belongs to the profile. What a deployment then *does* about
218/// an Issuer-asserted acknowledgement -- refuse, warn, or record and carry on
219/// -- is a matter of that deployment's risk appetite and is deliberately not
220/// specified here.
221///
222/// # Why an unrecognised value is preserved rather than rejected
223///
224/// [`Self::Unrecognized`] carries the exact string that was on the wire. Two
225/// rules sit next to each other and they are separate requirements:
226///
227/// 1. The three named values remain distinguishable from each other.
228/// 2. An unrecognised value is surfaced as unrecognised. It is not read as
229///    [`Self::ThirdParty`] and it is not normalised to [`Self::NoAcknowledgement`].
230///
231/// The second rule is the one that is easy to get wrong, because the obvious
232/// instinct is to fail closed on anything unrecognised. **That instinct is
233/// correct for an enumeration feeding a pre-action gate and wrong here, and the
234/// difference is the cost function underneath it.** At a gate, refusing costs
235/// availability and the action simply does not happen. This member is a
236/// descriptive property of a record that gets read afterwards, often by
237/// somebody reconstructing an event months later. Refusing there means refusing
238/// the record, and refusing the record destroys the reconstruction the record
239/// exists to serve. Once the engagement has already happened, refusal is not
240/// the conservative choice.
241///
242/// Normalising to [`Self::NoAcknowledgement`] is the same collapse pointing the
243/// other way: it manufactures a positive claim that the operations layer
244/// returned nothing, which is a substantive statement about what happened at the
245/// site and may be false.
246///
247/// So the profile is **closed on the producing side and tolerant on the
248/// consuming side.** A conforming producer MUST emit one of the three named
249/// values; a verifier MUST NOT refuse a receipt solely because this member
250/// carries something else, and MUST surface it as unrecognised.
251///
252/// # Serialization
253///
254/// `Serialize` and `Deserialize` are written by hand rather than derived.
255/// `#[serde(other)]` would discard the unrecognised string, which fails rule 2:
256/// the value has to stay available to the reader, not merely be distinguishable
257/// as "not one of ours". Round-tripping is byte-exact, which matters because the
258/// payload is signed over its JCS serialization -- a variant that re-serialised
259/// to anything other than the original bytes would invalidate the signature.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub enum AckProvenance {
262    /// An independent operations layer produced the acknowledgement. Wire value
263    /// `THIRD_PARTY`.
264    ///
265    /// Under a write-only posture this remains the Issuer's claim about a third
266    /// party rather than an independently verified fact. The profile does not
267    /// name this state `CONFIRMED` for exactly that reason.
268    ThirdParty,
269    /// The Issuer authored the acknowledgement object itself. Wire value
270    /// `ISSUER_ASSERTED`.
271    IssuerAsserted,
272    /// No acknowledgement was obtained. Wire value `NONE`.
273    ///
274    /// Named `NoAcknowledgement` in Rust rather than `None`, which would shadow
275    /// [`Option::None`] at every use site and produce compiler messages that
276    /// read as if the option type were involved. The wire string is pinned by
277    /// hand and is unaffected.
278    NoAcknowledgement,
279    /// A value outside the closed set, preserved exactly as it appeared.
280    ///
281    /// A receipt carrying this is not conforming. It still parses, still
282    /// validates, and still presents this member to the reader, for the reasons
283    /// in the type-level documentation.
284    Unrecognized(String),
285}
286
287/// The attestation-binding mode a receipt was produced under.
288///
289/// # Why this is in the payload
290///
291/// `-02` required a Verifier to obtain this from a document the Issuer
292/// published separately, and simultaneously declined to specify that document.
293/// Two problems followed. The obligation could not be discharged, because there
294/// was nothing to fetch. And even had there been, a per-receipt fact would have
295/// been resolved from a document that describes an Issuer rather than a
296/// receipt, so the introduction's non-extractability property stopped being
297/// checkable from the presented bytes.
298///
299/// The mode was fixed at the instant the receipt was signed and was known to
300/// the signer. Carrying it costs one string.
301///
302/// # Why an unrecognised value is rejected here and preserved in [`AckProvenance`]
303///
304/// The two members answer different questions and the difference is not
305/// stylistic. `adapter.ackProvenance` qualifies a record of something that
306/// already happened, so refusing to parse it destroys the reconstruction the
307/// receipt exists for, and the honest move is to surface the unknown.
308///
309/// `attestation.bindingMode` selects which security property a reader is
310/// entitled to rely on. There is no safe reading of an unrecognised value: it
311/// cannot be treated as direct-witness without asserting a property nobody
312/// claimed, and treating it as delegated-witness invents a delegation
313/// credential to go looking for. So the profile closes the set and this rejects.
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum BindingMode {
316    /// The signing key is the TEE signing key. Wire value `DIRECT_WITNESS`.
317    DirectWitness,
318    /// The signing key is the Issuer's own, authorised by a TEE-issued
319    /// delegation credential. Wire value `DELEGATED_WITNESS`.
320    ///
321    /// A reader of such a receipt obtains a weaker property than the
322    /// non-extractability one, and the delegation credential is resolved out of
323    /// band. Nothing in this repository resolves it; see `KNOWN-LIMITATIONS.md`.
324    DelegatedWitness,
325    /// A value outside the closed set, preserved exactly as it appeared.
326    ///
327    /// Retained rather than dropped so a reader can report what was actually
328    /// present. A payload carrying this fails validation.
329    Unrecognized(String),
330}
331
332impl BindingMode {
333    /// Returns the wire string for this value.
334    #[must_use]
335    pub fn as_wire_str(&self) -> &str {
336        match self {
337            Self::DirectWitness => BINDING_MODE_DIRECT_WITNESS,
338            Self::DelegatedWitness => BINDING_MODE_DELEGATED_WITNESS,
339            Self::Unrecognized(raw) => raw,
340        }
341    }
342
343    /// Returns `true` when the value is outside the closed set the profile names.
344    #[must_use]
345    pub const fn is_unrecognized(&self) -> bool {
346        matches!(self, Self::Unrecognized(_))
347    }
348}
349
350impl Serialize for BindingMode {
351    fn serialize<S: serde::Serializer>(
352        &self,
353        serializer: S,
354    ) -> core::result::Result<S::Ok, S::Error> {
355        serializer.serialize_str(self.as_wire_str())
356    }
357}
358
359impl<'de> Deserialize<'de> for BindingMode {
360    fn deserialize<D: serde::Deserializer<'de>>(
361        deserializer: D,
362    ) -> core::result::Result<Self, D::Error> {
363        let raw = String::deserialize(deserializer)?;
364        Ok(match raw.as_str() {
365            BINDING_MODE_DIRECT_WITNESS => Self::DirectWitness,
366            BINDING_MODE_DELEGATED_WITNESS => Self::DelegatedWitness,
367            // Parsed, not accepted. Deserialization keeps the raw string so the
368            // value can be reported; `validate` is what refuses it. Rejecting
369            // here instead would surface as a parse error and lose the value.
370            _ => Self::Unrecognized(raw),
371        })
372    }
373}
374
375impl AckProvenance {
376    /// Returns the wire string for this value.
377    #[must_use]
378    pub fn as_wire_str(&self) -> &str {
379        match self {
380            Self::ThirdParty => ACK_PROVENANCE_THIRD_PARTY,
381            Self::IssuerAsserted => ACK_PROVENANCE_ISSUER_ASSERTED,
382            Self::NoAcknowledgement => ACK_PROVENANCE_NONE,
383            Self::Unrecognized(raw) => raw,
384        }
385    }
386
387    /// Returns `true` when the value is outside the closed set the profile names.
388    ///
389    /// A verifier that surfaces the acknowledgement-provenance state to a reader
390    /// uses this rather than comparing against the named variants, so that an
391    /// unrecognised value cannot be silently folded into one of them.
392    #[must_use]
393    pub const fn is_unrecognized(&self) -> bool {
394        matches!(self, Self::Unrecognized(_))
395    }
396}
397
398impl Serialize for AckProvenance {
399    fn serialize<S: serde::Serializer>(
400        &self,
401        serializer: S,
402    ) -> core::result::Result<S::Ok, S::Error> {
403        serializer.serialize_str(self.as_wire_str())
404    }
405}
406
407impl<'de> Deserialize<'de> for AckProvenance {
408    fn deserialize<D: serde::Deserializer<'de>>(
409        deserializer: D,
410    ) -> core::result::Result<Self, D::Error> {
411        let raw = String::deserialize(deserializer)?;
412        Ok(match raw.as_str() {
413            ACK_PROVENANCE_THIRD_PARTY => Self::ThirdParty,
414            ACK_PROVENANCE_ISSUER_ASSERTED => Self::IssuerAsserted,
415            ACK_PROVENANCE_NONE => Self::NoAcknowledgement,
416            // Deliberately not an error. See the type-level documentation: this
417            // is a record read after the fact, and refusing it destroys the
418            // reconstruction it exists for. The raw string is retained so the
419            // value stays distinguishable from both THIRD_PARTY and NONE.
420            _ => Self::Unrecognized(raw),
421        })
422    }
423}
424
425/// Whether the Site Owner and the Issuer are affiliated parties.
426///
427/// # Why this member exists
428///
429/// Nothing else in a receipt tells a reader whether the party that controls the
430/// site and the party that issued the receipt are related. A reader who cannot
431/// see the relationship has no way to check it, and the natural reading of
432/// silence is that the two are unrelated. That reading is a claim, and it is a
433/// claim nobody made.
434///
435/// The failure is the same shape as an unrecognised [`AckProvenance`] read as
436/// [`AckProvenance::ThirdParty`], one level up: an unknown resolved in the
437/// receipt's favour rather than surfaced as unknown. So the fix is the same.
438/// Name the unknown, and make naming it the default rather than the exception.
439///
440/// This does not overlap the profile's prohibition on collapsing two of the
441/// three trust roles into one principal. That rule addresses one principal
442/// wearing two hats. This member addresses the ordinary and far more common
443/// case of three distinct principals, two of which are related.
444///
445/// # What the profile does and does not do with it
446///
447/// The Issuer signs the receipt, so this member is the Issuer's statement about
448/// the Issuer's own standing. **The profile records that statement. It does not
449/// verify it, and no verification of it is possible from the receipt bytes.**
450///
451/// That is a weaker guarantee than it first appears to be and it is still worth
452/// having. A false value here is a false statement inside a signed, timestamped,
453/// registered record, attributable to the key that signed it and discoverable by
454/// anyone auditing the log. Silence is unfalsifiable and costs a dishonest
455/// Issuer nothing at all. The whole profile rests on that trade.
456///
457/// # Why REQUIRED rather than optional
458///
459/// Identical reasoning to [`AckProvenance`]: an absent member would itself have
460/// to be assigned a meaning, and every available meaning is wrong. Read as
461/// independent, it manufactures the claim this member exists to prevent. Read as
462/// affiliated, it defames an Issuer that simply predates the member. Read as
463/// unknown, it duplicates [`Self::NotDisclosed`] while being indistinguishable
464/// from a producer that forgot.
465///
466/// [`Self::NotDisclosed`] is the honest default value. A producer that has not
467/// established the relationship, or that declines to state it, emits it
468/// explicitly.
469///
470/// # Why an unrecognised value is preserved rather than rejected
471///
472/// See [`AckProvenance`]. The cost function is the same one: this is a
473/// descriptive property of a record read after the fact, refusing the record
474/// destroys the reconstruction it exists to serve, and normalising to
475/// [`Self::NotDisclosed`] manufactures a positive claim that nobody disclosed
476/// anything when in fact somebody may have disclosed something this build does
477/// not recognise.
478///
479/// # A standing fact carried per receipt
480///
481/// Affiliation between two principals is a standing relationship, not a fact
482/// about one engagement. Carrying it per receipt means a chain can disagree with
483/// itself. A verifier that observes the value change within a single chain
484/// surfaces the change rather than taking the later value as current, in the same
485/// way an ordering that cannot be established is surfaced as undetermined rather
486/// than guessed. This crate exposes [`Payload::issuer_affiliation`] so a chain
487/// verifier can make that comparison; the comparison itself is a chain-level
488/// concern and is not performed here.
489#[derive(Debug, Clone, PartialEq, Eq)]
490pub enum IssuerAffiliation {
491    /// The Site Owner and the Issuer are affiliated parties, and the Issuer
492    /// discloses it. Wire value `AFFILIATED`.
493    ///
494    /// The profile does not prohibit this arrangement. It requires that the
495    /// weaker standing of a receipt issued under it be visible rather than
496    /// implied, which is the same treatment already given to an Issuer that
497    /// registers with a Transparency Service it operates itself.
498    Affiliated,
499    /// The Issuer asserts that it and the Site Owner are unaffiliated. Wire
500    /// value `INDEPENDENT`.
501    ///
502    /// An assertion by the Issuer about the Issuer. Not independently verified,
503    /// and not verifiable from the receipt bytes.
504    Independent,
505    /// The relationship is not disclosed. Wire value `NOT_DISCLOSED`.
506    ///
507    /// The default, and an honest one. It states that nobody made a claim, which
508    /// is different from a claim of independence and must not be read as one.
509    NotDisclosed,
510    /// A value outside the closed set, preserved exactly as it appeared.
511    ///
512    /// A receipt carrying this is not conforming. It still parses, still
513    /// validates, and still presents this member to the reader.
514    Unrecognized(String),
515}
516
517impl IssuerAffiliation {
518    /// Returns the wire string for this value.
519    #[must_use]
520    pub fn as_wire_str(&self) -> &str {
521        match self {
522            Self::Affiliated => ISSUER_AFFILIATION_AFFILIATED,
523            Self::Independent => ISSUER_AFFILIATION_INDEPENDENT,
524            Self::NotDisclosed => ISSUER_AFFILIATION_NOT_DISCLOSED,
525            Self::Unrecognized(raw) => raw,
526        }
527    }
528
529    /// Returns `true` when the value is outside the closed set the profile names.
530    ///
531    /// A verifier that surfaces the affiliation state to a reader uses this
532    /// rather than comparing against the named variants, so that an unrecognised
533    /// value cannot be silently folded into one of them.
534    #[must_use]
535    pub const fn is_unrecognized(&self) -> bool {
536        matches!(self, Self::Unrecognized(_))
537    }
538
539    /// Returns `true` when the receipt carries no disclosure of the relationship.
540    ///
541    /// Deliberately distinct from [`Self::is_unrecognized`]. A reader that
542    /// collapses "nobody disclosed" and "disclosed something I do not recognise"
543    /// into one state loses the difference between an Issuer that declined to
544    /// speak and an Issuer that spoke in a vocabulary this build predates.
545    #[must_use]
546    pub const fn is_not_disclosed(&self) -> bool {
547        matches!(self, Self::NotDisclosed)
548    }
549}
550
551impl Serialize for IssuerAffiliation {
552    fn serialize<S: serde::Serializer>(
553        &self,
554        serializer: S,
555    ) -> core::result::Result<S::Ok, S::Error> {
556        serializer.serialize_str(self.as_wire_str())
557    }
558}
559
560impl<'de> Deserialize<'de> for IssuerAffiliation {
561    fn deserialize<D: serde::Deserializer<'de>>(
562        deserializer: D,
563    ) -> core::result::Result<Self, D::Error> {
564        let raw = String::deserialize(deserializer)?;
565        Ok(match raw.as_str() {
566            ISSUER_AFFILIATION_AFFILIATED => Self::Affiliated,
567            ISSUER_AFFILIATION_INDEPENDENT => Self::Independent,
568            ISSUER_AFFILIATION_NOT_DISCLOSED => Self::NotDisclosed,
569            // Deliberately not an error, for the reasons in the type-level
570            // documentation. The raw string is retained so the value stays
571            // distinguishable from both INDEPENDENT and NOT_DISCLOSED.
572            _ => Self::Unrecognized(raw),
573        })
574    }
575}
576
577#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
578#[serde(deny_unknown_fields, rename_all = "camelCase")]
579struct Chain {
580    seq: u64,
581    #[serde(deserialize_with = "deserialize_required_option")]
582    prev_hash: Option<String>,
583    hash: String,
584}
585
586/// Reads only `spec` and rejects a version this build does not implement.
587///
588/// This runs **before** the full deserialization, and the ordering is the whole
589/// point of it. Deserializing first and checking the version afterwards produces
590/// an honest answer only while every revision happens to carry the same members.
591/// The moment a revision requires a member an earlier one did not carry -- which
592/// is exactly what `wilder.pser/0.5` does with `adapter.ackProvenance` -- a
593/// receipt from the earlier revision fails on the missing member and the caller
594/// is told a member is absent when the real and far more useful answer is that
595/// the receipt is from a revision this build does not implement. One diagnosis
596/// sends an operator looking for a malformed producer; the other tells them to
597/// upgrade the verifier.
598///
599/// Measured on `wilder.pser/0.3` before this check existed: a receipt whose
600/// version *and* member shape both differed reported
601/// `unknown field ...`, while a receipt differing in version alone reported
602/// `unsupported spec version` correctly. Only the first case was wrong, and only
603/// the first case is the one a real version skew produces.
604///
605/// The permissive intermediate parse is deliberate: this stage must tolerate a
606/// document it cannot fully model, or it could not report the version at all.
607fn check_spec_version(bytes: &[u8]) -> Result<()> {
608    #[derive(Deserialize)]
609    struct SpecOnly {
610        spec: String,
611    }
612
613    // A document too malformed to yield a `spec` string is not a version
614    // problem. Say nothing and let the full parse produce the real diagnosis.
615    if let Ok(probe) = serde_json::from_slice::<SpecOnly>(bytes)
616        && !is_supported_spec(&probe.spec)
617    {
618        return Err(Error::Validation("unsupported spec version"));
619    }
620    Ok(())
621}
622
623fn deserialize_required_option<'de, D, T>(
624    deserializer: D,
625) -> core::result::Result<Option<T>, D::Error>
626where
627    D: serde::Deserializer<'de>,
628    T: Deserialize<'de>,
629{
630    Option::<T>::deserialize(deserializer)
631}
632
633/// Strongly typed `wilder.pser/0.5` payload.
634#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
635#[serde(deny_unknown_fields, rename_all = "camelCase")]
636pub struct Payload {
637    spec: String,
638    id: String,
639    ts: String,
640    issuer_affiliation: IssuerAffiliation,
641    site: Site,
642    actor: Actor,
643    engagement: Engagement,
644    attestation: Attestation,
645    adapter: Adapter,
646    chain: Chain,
647}
648
649impl Payload {
650    /// Parses JSON and validates every profile rule, including `chain.hash`.
651    ///
652    /// # Errors
653    ///
654    /// Returns an error when JSON parsing or any profile validation rule fails.
655    pub fn from_json(bytes: &[u8]) -> Result<Self> {
656        check_spec_version(bytes)?;
657        let payload: Self =
658            serde_json::from_slice(bytes).map_err(|error| Error::Json(error.to_string()))?;
659        payload.validate()?;
660        Ok(payload)
661    }
662
663    /// Parses producer input, replaces the supplied `chain.hash`, and validates the result.
664    ///
665    /// # Errors
666    ///
667    /// Returns an error when JSON parsing, hashing, or any profile validation rule fails.
668    pub fn from_json_for_production(bytes: &[u8]) -> Result<Self> {
669        check_spec_version(bytes)?;
670        let mut payload: Self =
671            serde_json::from_slice(bytes).map_err(|error| Error::Json(error.to_string()))?;
672        payload.update_chain_hash()?;
673        payload.validate()?;
674        Ok(payload)
675    }
676
677    /// Returns the RFC 8785 JCS serialization of the complete payload.
678    ///
679    /// # Errors
680    ///
681    /// Returns an error if the payload cannot be serialized.
682    pub fn to_jcs(&self) -> Result<Vec<u8>> {
683        let value = serde_json::to_value(self).map_err(|error| Error::Json(error.to_string()))?;
684        canonicalize_value(&value)
685    }
686
687    /// Returns the profile version string.
688    #[must_use]
689    pub fn spec(&self) -> &str {
690        &self.spec
691    }
692
693    /// Returns the stable site identifier used as the CWT subject.
694    #[must_use]
695    pub fn site_id(&self) -> &str {
696        &self.site.id
697    }
698
699    /// Returns the witness key identifier used as the CWT issuer by the CLI.
700    #[must_use]
701    pub const fn attestation_binding_mode(&self) -> &BindingMode {
702        &self.attestation.binding_mode
703    }
704
705    /// Key identifier of the TEE signing key.
706    pub fn witness_key(&self) -> &str {
707        &self.attestation.witness_key
708    }
709
710    /// Returns the stored chain digest.
711    #[must_use]
712    pub fn chain_hash(&self) -> &str {
713        &self.chain.hash
714    }
715
716    /// Returns the receipt's position in its chain.
717    #[must_use]
718    pub fn chain_seq(&self) -> u64 {
719        self.chain.seq
720    }
721
722    /// Returns the preceding receipt's `chain.hash`, or `None` at sequence zero.
723    #[must_use]
724    pub fn chain_prev_hash(&self) -> Option<&str> {
725        self.chain.prev_hash.as_deref()
726    }
727
728    /// Returns whether the Site Owner and the Issuer are affiliated parties.
729    ///
730    /// A value outside the closed set is returned as
731    /// [`IssuerAffiliation::Unrecognized`] carrying the original string, never
732    /// folded into one of the named values. [`IssuerAffiliation::NotDisclosed`]
733    /// means nobody made a claim and MUST NOT be read as a claim of
734    /// independence. See [`IssuerAffiliation`] for why.
735    #[must_use]
736    pub const fn issuer_affiliation(&self) -> &IssuerAffiliation {
737        &self.issuer_affiliation
738    }
739
740    /// Returns how the acknowledgement in `adapter.ackDigest` was obtained.
741    ///
742    /// A value outside the closed set is returned as
743    /// [`AckProvenance::Unrecognized`] carrying the original string, never
744    /// folded into one of the named values. See [`AckProvenance`] for why.
745    #[must_use]
746    pub const fn adapter_ack_provenance(&self) -> &AckProvenance {
747        &self.adapter.ack_provenance
748    }
749
750    /// Returns the stable actor identifier.
751    #[must_use]
752    pub fn actor_id(&self) -> &str {
753        &self.actor.id
754    }
755
756    /// Returns the operator string associated with the actor.
757    #[must_use]
758    pub fn actor_operator(&self) -> &str {
759        &self.actor.operator
760    }
761
762    /// Returns the stable engagement identifier.
763    #[must_use]
764    pub fn engagement_id(&self) -> &str {
765        &self.engagement.id
766    }
767
768    /// Returns the engagement type string (RFC 3986 URI or short identifier per §6 of the draft).
769    #[must_use]
770    pub fn engagement_type(&self) -> &str {
771        &self.engagement.r#type
772    }
773
774    /// Returns the RFC 3339 UTC start of the engagement window.
775    #[must_use]
776    pub fn engagement_window_start(&self) -> &str {
777        &self.engagement.window.start
778    }
779
780    /// Returns the RFC 3339 UTC end of the engagement window.
781    #[must_use]
782    pub fn engagement_window_end(&self) -> &str {
783        &self.engagement.window.end
784    }
785
786    /// Returns the SHA-256 digest of the raw evidence bundle referenced by this engagement.
787    #[must_use]
788    pub fn engagement_evidence_digest(&self) -> &str {
789        &self.engagement.evidence_digest
790    }
791
792    /// Returns the TEE class identifier.
793    #[must_use]
794    pub fn attestation_tee_class(&self) -> &str {
795        &self.attestation.tee_class
796    }
797
798    /// Returns the SHA-256 digest of the sealed evidence blob.
799    #[must_use]
800    pub fn sealed_evidence_digest(&self) -> &str {
801        &self.attestation.sealed_evidence.digest
802    }
803
804    /// Returns the size in bytes of the sealed evidence blob.
805    #[must_use]
806    pub fn sealed_evidence_size_bytes(&self) -> u64 {
807        self.attestation.sealed_evidence.size_bytes
808    }
809
810    /// Returns the sealed evidence encoding identifier.
811    #[must_use]
812    pub fn sealed_evidence_encoding(&self) -> &str {
813        &self.attestation.sealed_evidence.encoding
814    }
815
816    /// Returns the operations-layer system identifier.
817    ///
818    /// This value selects which write-in adapter is responsible for pushing this receipt.
819    /// Values are defined by the PSER profile registry (see §6 of the draft). Example
820    /// values: `"buildium"`, `"propertymeld"`.
821    #[must_use]
822    pub fn adapter_system(&self) -> &str {
823        &self.adapter.system
824    }
825
826    /// Returns the opaque per-system endpoint identifier.
827    ///
828    /// The value is interpreted by the target adapter, not by `pask-wire`. For the
829    /// `buildium` adapter it is the Buildium rental property ID; for other adapters,
830    /// consult the adapter documentation.
831    #[must_use]
832    pub fn adapter_endpoint(&self) -> &str {
833        &self.adapter.endpoint
834    }
835
836    /// Returns the RFC 3339 UTC timestamp at which the write-in was posted.
837    #[must_use]
838    pub fn adapter_posted_at(&self) -> &str {
839        &self.adapter.posted_at
840    }
841
842    /// Returns the SHA-256 digest of the operations-layer acknowledgement.
843    #[must_use]
844    pub fn adapter_ack_digest(&self) -> &str {
845        &self.adapter.ack_digest
846    }
847
848    /// Returns whether the receipt is marked WRITE_ONLY.
849    ///
850    /// Every `wilder.pser/0.3` receipt that parses successfully MUST be WRITE_ONLY;
851    /// this accessor exists so downstream adapters can enforce the property as
852    /// defense-in-depth without pattern-matching the internal enum.
853    #[must_use]
854    pub fn adapter_is_write_only(&self) -> bool {
855        matches!(self.adapter.mode, AdapterMode::WriteOnly)
856    }
857
858    pub(crate) fn parse_canonical(bytes: &[u8]) -> Result<Self> {
859        let payload = Self::from_json(bytes)?;
860        if payload.to_jcs()?.as_slice() != bytes {
861            return Err(Error::NonCanonicalPayload);
862        }
863        Ok(payload)
864    }
865
866    fn update_chain_hash(&mut self) -> Result<()> {
867        self.chain.hash = self.expected_chain_hash()?;
868        Ok(())
869    }
870
871    fn expected_chain_hash(&self) -> Result<String> {
872        let mut value =
873            serde_json::to_value(self).map_err(|error| Error::Json(error.to_string()))?;
874        let chain = value
875            .get_mut("chain")
876            .and_then(serde_json::Value::as_object_mut)
877            .ok_or(Error::Validation("chain must be an object"))?;
878        chain.remove("hash");
879        let canonical = canonicalize_value(&value)?;
880        Ok(sha256_prefixed(&canonical))
881    }
882
883    fn validate(&self) -> Result<()> {
884        if !is_supported_spec(&self.spec) {
885            return Err(Error::Validation("unsupported spec version"));
886        }
887        // The profile closes this set. See `BindingMode` for why an unknown is
888        // refused here while an unknown `adapter.ackProvenance` is surfaced.
889        if self.attestation.binding_mode.is_unrecognized() {
890            return Err(Error::Validation("unrecognized attestation binding mode"));
891        }
892        for value in [
893            &self.id,
894            &self.site.id,
895            &self.site.envelope.id,
896            &self.actor.id,
897            &self.actor.operator,
898            &self.engagement.id,
899            &self.engagement.r#type,
900            &self.attestation.tee_class,
901            &self.attestation.platform_evidence.encoding,
902            &self.attestation.sealed_evidence.encoding,
903            &self.attestation.witness_key,
904            &self.adapter.system,
905            &self.adapter.endpoint,
906        ] {
907            if value.is_empty() {
908                return Err(Error::Validation("required string must not be empty"));
909            }
910        }
911        validate_utc(&self.ts)?;
912        validate_optional_window(
913            self.site.envelope.temporal.starts.as_deref(),
914            self.site.envelope.temporal.ends.as_deref(),
915        )?;
916        let start = validate_utc(&self.engagement.window.start)?;
917        let end = validate_utc(&self.engagement.window.end)?;
918        if end < start {
919            return Err(Error::Validation(
920                "engagement window end precedes its start",
921            ));
922        }
923        validate_utc(&self.adapter.posted_at)?;
924        validate_sha256(&self.site.envelope.digest)?;
925        validate_sha256(&self.engagement.evidence_digest)?;
926        validate_sha256(&self.attestation.measured_boot.chain)?;
927        validate_sha256(&self.attestation.platform_evidence.digest)?;
928        validate_sha256(&self.attestation.sealed_evidence.digest)?;
929        for component in &self.attestation.measured_boot.components {
930            if component.name.is_empty() {
931                return Err(Error::Validation(
932                    "measured-boot component name must not be empty",
933                ));
934            }
935            validate_sha256(&component.digest)?;
936        }
937        let validity_start = validate_utc(&self.attestation.validity.not_before)?;
938        let validity_end = validate_utc(&self.attestation.validity.not_after)?;
939        // Q3, ruled 2026-08-09: `pask-wire` and `pask-attest` MUST enforce the
940        // identical rule. `pask-attest` rejects a zero-length window
941        // (`validity.rs`, `not_after <= not_before`); this crate previously
942        // accepted one, so a payload could pass one crate and fail the other.
943        // That is the Axis-B defect class this revision exists to close, so it
944        // is not left standing inside the revision that closes it.
945        //
946        // The single rule, stated once: notAfter MUST be strictly later than
947        // notBefore. A zero-length window asserts validity for an instant of
948        // zero duration and has no legitimate producer.
949        //
950        // Out of scope in `-01`: containment of `ts` within the window is NOT
951        // required in this revision. The interval is carried and its internal
952        // consistency is checked; nothing validates an event timestamp against
953        // it. Stated plainly so no policy author writes a rule this
954        // implementation does not enforce.
955        if validity_end <= validity_start {
956            return Err(Error::Validation(
957                "attestation validity notAfter must be strictly later than notBefore",
958            ));
959        }
960        // Issue #30: Under wilder.pser/0.6, the Verifier MUST check that
961        // the receipt-issuance timestamp `ts` falls within the attestation
962        // validity interval [notBefore, notAfter] using inclusive endpoints.
963        // This check does not apply to wilder.pser/0.5, whose profile does not
964        // require timestamp containment. A separately identified local policy
965        // may perform an additional check without changing the historical
966        // profile contract.
967        if self.spec == SPEC_VERSION_06 {
968            let ts = validate_utc(&self.ts)?;
969            if ts < validity_start || ts > validity_end {
970                return Err(Error::Validation(
971                    "receipt-issuance timestamp is outside the attestation validity interval",
972                ));
973            }
974        }
975        validate_sha256(&self.adapter.ack_digest)?;
976        match (self.chain.seq, self.chain.prev_hash.as_deref()) {
977            (0, None) => {}
978            (0, Some(_)) => {
979                return Err(Error::Validation("sequence zero must have null prevHash"));
980            }
981            (_, Some(previous)) => validate_sha256(previous)?,
982            (_, None) => {
983                return Err(Error::Validation("nonzero sequence must include prevHash"));
984            }
985        }
986        validate_sha256(&self.chain.hash)?;
987        if self.chain.hash != self.expected_chain_hash()? {
988            return Err(Error::Validation("chain.hash does not match payload"));
989        }
990        Ok(())
991    }
992}
993
994/// Canonicalizes one JSON value according to RFC 8785.
995///
996/// # Errors
997///
998/// Returns an error for malformed JSON or values outside the finite I-JSON number domain.
999pub fn canonicalize_json(bytes: &[u8]) -> Result<Vec<u8>> {
1000    let value: serde_json::Value =
1001        serde_json::from_slice(bytes).map_err(|error| Error::Json(error.to_string()))?;
1002    canonicalize_value(&value)
1003}
1004
1005fn canonicalize_value(value: &serde_json::Value) -> Result<Vec<u8>> {
1006    let mut output = Vec::new();
1007    write_canonical(value, &mut output)?;
1008    Ok(output)
1009}
1010
1011fn write_canonical(value: &serde_json::Value, output: &mut Vec<u8>) -> Result<()> {
1012    match value {
1013        serde_json::Value::Null => output.extend_from_slice(b"null"),
1014        serde_json::Value::Bool(true) => output.extend_from_slice(b"true"),
1015        serde_json::Value::Bool(false) => output.extend_from_slice(b"false"),
1016        serde_json::Value::Number(number) => {
1017            let text = if let Some(integer) = number.as_i64() {
1018                integer.to_string()
1019            } else if let Some(integer) = number.as_u64() {
1020                integer.to_string()
1021            } else {
1022                let number = number
1023                    .as_f64()
1024                    .ok_or(Error::Jcs("number is not representable as f64".to_owned()))?;
1025                if !number.is_finite() {
1026                    return Err(Error::Jcs("JCS numbers must be finite".to_owned()));
1027                }
1028                ryu_js::Buffer::new().format_finite(number).to_owned()
1029            };
1030            output.extend_from_slice(text.as_bytes());
1031        }
1032        serde_json::Value::String(string) => {
1033            let escaped =
1034                serde_json::to_string(string).map_err(|error| Error::Jcs(error.to_string()))?;
1035            output.extend_from_slice(escaped.as_bytes());
1036        }
1037        serde_json::Value::Array(values) => {
1038            output.push(b'[');
1039            for (index, item) in values.iter().enumerate() {
1040                if index != 0 {
1041                    output.push(b',');
1042                }
1043                write_canonical(item, output)?;
1044            }
1045            output.push(b']');
1046        }
1047        serde_json::Value::Object(object) => {
1048            let mut entries: Vec<_> = object.iter().collect();
1049            entries.sort_by(|(left, _), (right, _)| left.encode_utf16().cmp(right.encode_utf16()));
1050            output.push(b'{');
1051            for (index, (key, item)) in entries.into_iter().enumerate() {
1052                if index != 0 {
1053                    output.push(b',');
1054                }
1055                let escaped =
1056                    serde_json::to_string(key).map_err(|error| Error::Jcs(error.to_string()))?;
1057                output.extend_from_slice(escaped.as_bytes());
1058                output.push(b':');
1059                write_canonical(item, output)?;
1060            }
1061            output.push(b'}');
1062        }
1063    }
1064    Ok(())
1065}
1066
1067fn validate_utc(value: &str) -> Result<OffsetDateTime> {
1068    if !value.ends_with('Z') {
1069        return Err(Error::Validation("timestamp must use UTC Z notation"));
1070    }
1071    OffsetDateTime::parse(value, &Rfc3339)
1072        .map_err(|_| Error::Validation("timestamp must be valid RFC 3339"))
1073}
1074
1075fn validate_optional_window(starts: Option<&str>, ends: Option<&str>) -> Result<()> {
1076    let starts = starts.map(validate_utc).transpose()?;
1077    let ends = ends.map(validate_utc).transpose()?;
1078    if let (Some(start), Some(end)) = (starts, ends)
1079        && end < start
1080    {
1081        return Err(Error::Validation("temporal end precedes its start"));
1082    }
1083    Ok(())
1084}