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}