Skip to main content

net/adapter/net/behavior/sensing/
wire.rs

1//! SI-1 wire layer: committed subprotocol ids, the frozen postcard
2//! codec for the 0x0C02/0x0C03 payloads, and attestation signing +
3//! verification honoring the §4.2 transcript invariant
4//! (`docs/internal/plans/SENSING_INTEREST_COALESCING_PLAN.md`, v4.3).
5//!
6//! # Codec (postcard, strict)
7//!
8//! postcard is the tree's canonical wire codec (fold, group, subnet,
9//! RedEX); both sensing payloads encode through the serde derives on
10//! the semantic types. Decoding is **strict**: the payload must be
11//! exactly one object — trailing bytes are rejected
12//! ([`WireError::TrailingBytes`], via `postcard::take_from_bytes`
13//! with an empty-rest requirement), and payloads over
14//! [`MAX_SENSING_FRAME_BYTES`] (4 KiB) are rejected before parsing.
15//! 4 KiB comfortably bounds every legal frame: inline constraints
16//! are capped at 1 KiB (plan §5) and everything else is small;
17//! anything larger is malformed or hostile.
18//!
19//! Encoding note: the 32-byte identity newtypes ([`Digest256`],
20//! [`AudienceScopeCommitment`]) split on `is_human_readable` — raw
21//! 33-byte `serialize_bytes` under postcard (this codec), lowercase
22//! hex under JSON-style encodings (see `impl_hex32_serde` in
23//! `identity.rs`). The split landed BEFORE any deployment existed
24//! (the SI-1 as-built note records the hex-first history), so it was
25//! an encoding choice, not a wire break.
26//!
27//! # Signature transcript (encoding-independent)
28//!
29//! The [`ReadinessAttestation`] signature never signs postcard
30//! bytes: like the digest preimages in `identity.rs`, the transcript
31//! is a hand-rolled, domain-separated, injective byte string —
32//! length-prefixed where variable, fixed-width everywhere else — so
33//! a codec migration can never invalidate old signatures or let two
34//! field tuples collide. It binds EXACTLY the §4.2 list: protocol
35//! domain/version (the [`ATTESTATION_SIG_DOMAIN`] derive-key
36//! context), interest digest, origin NodeId, origin incarnation,
37//! capability id, capability generation, status + reason, estimated
38//! start, sequence, promised cadence, audience scope. Because the
39//! provider validated the interest digest first
40//! ([`SensingInterestFrame::validate_provider_registration`]),
41//! signing it commits the attestation to the complete predicate +
42//! selector + mode + disclosure + audience identity.
43//!
44//! **What is signed:** ed25519 over the 32-byte
45//! `blake3::derive_key(ATTESTATION_SIG_DOMAIN, transcript)` digest —
46//! not over the raw transcript. Chosen so the signing input is
47//! constant-size at any fan-out, the domain separation rides the
48//! derive-key context (with the `v1` version inside it), and the
49//! same 32 bytes double as the attestation's semantic fingerprint
50//! ([`semantic_attestation`]): the SI-0 stand-in fingerprint is now
51//! the real signed-bytes digest, so equivocation detection at the
52//! seq gate keys on exactly what the origin signed.
53
54use std::fmt;
55use std::time::Duration;
56
57use super::super::super::identity::{EntityError, EntityId, EntityKeypair};
58use super::super::capability::Signature64;
59use super::continuity::AttestedStatus;
60use super::delivery::Attestation;
61use super::evaluator::StatusReason;
62use super::frames::SensingInterestFrame;
63use super::identity::{
64    AudienceScopeCommitment, CapabilityId, CapabilityInterestKey, Digest256, ProviderObservationKey,
65};
66use super::incarnation::Incarnation;
67
68/// Subprotocol id for [`SensingInterestFrame`] payloads —
69/// **committed** per the review-7 sign-off (plan v4.3 Status block:
70/// gates (a)–(s) verified, 0x0C02 MAY be committed). Sensing-owned;
71/// cross-referenced beside 0x0C00/0x0C01 in `behavior::broadcast`.
72///
73/// Mixed-version caveat (as 0x0C01): a node that does not know this
74/// id drops the packet at the dispatch loop's unknown-subprotocol
75/// guard and keeps pre-sensing behavior (per-branch fallback, plan
76/// §4.11) — but binaries older than that guard itself would
77/// mis-handle the frame as an opaque application event, so a true
78/// mixed-version deployment needs peers new enough to have the
79/// guard.
80pub const SUBPROTOCOL_SENSING_INTEREST: u16 = 0x0C02;
81
82/// Subprotocol id for [`ReadinessAttestation`] payloads —
83/// **committed** per the review-7 sign-off, exactly as
84/// [`SUBPROTOCOL_SENSING_INTEREST`] (same mixed-version caveat).
85pub const SUBPROTOCOL_READINESS_ATTESTATION: u16 = 0x0C03;
86
87/// SI-4: the session stream a PROVISIONAL attestation forward rides
88/// (plan §4.2/§4.4 — "the continuity-bearing flag is relay-authored
89/// envelope metadata, never signed content"). The flag's wire
90/// encoding is the hop-authored session ENVELOPE itself: a live,
91/// continuity-bearing forward travels on the standard stream
92/// (`SUBPROTOCOL_READINESS_ATTESTATION as u64`); a provisional one —
93/// a warm-start re-send, or any forward while the relay's own
94/// upstream continuity is not Established (the §4.4 hop rule) —
95/// travels on THIS stream. Same subprotocol id, byte-identical
96/// committed codec: the payload the origin signed is never touched,
97/// and the flag is authenticated by the hop session exactly like
98/// every other envelope field. A hostile relay could lie about the
99/// flag on either encoding — the §4.5 stated v1 trust assumption
100/// inside the owner-root boundary.
101pub const SENSING_PROVISIONAL_STREAM: u64 = 0x0001_0C03;
102
103/// Hard cap on one encoded sensing payload (either subprotocol):
104/// 4 KiB. Inline constraints are capped at 1 KiB (plan §5) and every
105/// other field is bounded and small, so any larger payload is
106/// malformed or hostile — enforced on encode AND before decode.
107pub const MAX_SENSING_FRAME_BYTES: usize = 4096;
108
109/// Domain-separation context for the attestation signature
110/// transcript: fed to `blake3::Hasher::new_derive_key`, so the
111/// signed digest can never collide with the interest/constraints
112/// digests or any other blake3 use in the tree. The `v1` is the
113/// transcript version — a field-list change means a new domain,
114/// never a silent re-interpretation.
115pub const ATTESTATION_SIG_DOMAIN: &str = "net.sensing.attestation.sig.v1";
116
117/// Why a sensing payload failed the wire codec.
118#[derive(Clone, PartialEq, Eq, Debug)]
119pub enum WireError {
120    /// The payload exceeds [`MAX_SENSING_FRAME_BYTES`] (checked on
121    /// encode and before decode).
122    Oversize {
123        /// The offending payload length.
124        len: usize,
125    },
126    /// postcard could not encode/decode the payload.
127    Codec(postcard::Error),
128    /// Bytes remained after exactly one object was decoded — a
129    /// sensing payload is never a concatenation (strict decode).
130    TrailingBytes {
131        /// How many undecoded bytes trailed the object.
132        remaining: usize,
133    },
134}
135
136impl fmt::Display for WireError {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        match self {
139            Self::Oversize { len } => {
140                write!(
141                    f,
142                    "sensing payload {len} B > {MAX_SENSING_FRAME_BYTES} B cap"
143                )
144            }
145            Self::Codec(error) => write!(f, "sensing payload codec failure: {error}"),
146            Self::TrailingBytes { remaining } => {
147                write!(f, "{remaining} trailing bytes after sensing payload")
148            }
149        }
150    }
151}
152
153impl std::error::Error for WireError {}
154
155fn encode_capped<T: serde::Serialize>(value: &T) -> Result<Vec<u8>, WireError> {
156    let bytes = postcard::to_allocvec(value).map_err(WireError::Codec)?;
157    if bytes.len() > MAX_SENSING_FRAME_BYTES {
158        return Err(WireError::Oversize { len: bytes.len() });
159    }
160    Ok(bytes)
161}
162
163fn decode_strict<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, WireError> {
164    if bytes.len() > MAX_SENSING_FRAME_BYTES {
165        return Err(WireError::Oversize { len: bytes.len() });
166    }
167    let (value, rest) = postcard::take_from_bytes::<T>(bytes).map_err(WireError::Codec)?;
168    if !rest.is_empty() {
169        return Err(WireError::TrailingBytes {
170            remaining: rest.len(),
171        });
172    }
173    Ok(value)
174}
175
176/// Encode one [`SensingInterestFrame`] as the 0x0C02 payload.
177pub fn encode_interest_frame(frame: &SensingInterestFrame) -> Result<Vec<u8>, WireError> {
178    encode_capped(frame)
179}
180
181/// Strict-decode one [`SensingInterestFrame`] from a 0x0C02 payload
182/// (size-capped; trailing bytes rejected). Decoding says nothing
183/// about authenticity or identity — intake validation
184/// ([`SensingInterestFrame::validated_spec`]) still applies.
185pub fn decode_interest_frame(bytes: &[u8]) -> Result<SensingInterestFrame, WireError> {
186    decode_strict(bytes)
187}
188
189/// Encode one [`ReadinessAttestation`] as the 0x0C03 payload.
190pub fn encode_attestation(attestation: &ReadinessAttestation) -> Result<Vec<u8>, WireError> {
191    encode_capped(attestation)
192}
193
194/// Strict-decode one [`ReadinessAttestation`] from a 0x0C03 payload
195/// (size-capped; trailing bytes rejected). Decoding says nothing
196/// about authenticity — [`verify_attestation`] still applies.
197pub fn decode_attestation(bytes: &[u8]) -> Result<ReadinessAttestation, WireError> {
198    decode_strict(bytes)
199}
200
201/// The 0x0C03 wire attestation (plan §4.2): one origin-signed
202/// readiness proof. Relays forward these bytes identically —
203/// suppress or delay, never alter; the continuity-bearing flag is
204/// relay-authored envelope metadata, never a field here (§4.4). The
205/// signature binds the §4.2 transcript (module docs) — it proves
206/// authorship, not recency (§4.5).
207#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
208pub struct ReadinessAttestation {
209    /// The VALIDATED capability-interest identity this proof
210    /// answers (the provider re-derived it before signing —
211    /// [`SensingInterestFrame::validate_provider_registration`]).
212    pub interest_digest: Digest256,
213    /// The signing provider's NodeId (derived from its entity
214    /// identity; cross-checked against the verifying [`EntityId`]).
215    pub origin: u64,
216    /// The provider's signed boot epoch (§4.6 ordering scope).
217    pub origin_incarnation: Incarnation,
218    /// Capability the predicate targets.
219    pub capability_id: CapabilityId,
220    /// The provider's OWN announce generation at evaluation time —
221    /// attested content, bound one level down in the observation key
222    /// (§3.2/§3.4).
223    pub capability_generation: u64,
224    /// The provider-signed status.
225    pub status: AttestedStatus,
226    /// Compact reason code beside the status (§4.4 projection).
227    pub status_reason: StatusReason,
228    /// Provider-side time-to-start estimate when Ready — each
229    /// consumer adds its own route estimate against its own budget
230    /// (§3.3); never an end-to-end claim.
231    pub estimated_start: Option<Duration>,
232    /// Signed per-(origin, incarnation, interest) sequence number
233    /// (strictly-newer admission, §4.6).
234    pub seq: u64,
235    /// The emission cadence the provider signed for this branch
236    /// (continuity-window input, §4.5).
237    pub promised_cadence: Duration,
238    /// The audience commitment the interest was validated under
239    /// (v1: canonical owner-root id) — signed, so a proof can never
240    /// be re-homed across audiences.
241    pub audience_scope: AudienceScopeCommitment,
242    /// ed25519 signature over the 32-byte transcript digest (module
243    /// docs).
244    pub signature: Signature64,
245}
246
247impl ReadinessAttestation {
248    /// The signable fields of this attestation (everything except
249    /// the signature).
250    pub fn unsigned(&self) -> UnsignedAttestation {
251        UnsignedAttestation {
252            interest_digest: self.interest_digest,
253            origin: self.origin,
254            origin_incarnation: self.origin_incarnation,
255            capability_id: self.capability_id.clone(),
256            capability_generation: self.capability_generation,
257            status: self.status,
258            status_reason: self.status_reason,
259            estimated_start: self.estimated_start,
260            seq: self.seq,
261            promised_cadence: self.promised_cadence,
262            audience_scope: self.audience_scope,
263        }
264    }
265
266    /// The 32-byte domain-separated digest of this attestation's
267    /// signature transcript — the exact bytes the origin signed, and
268    /// the semantic fingerprint ([`semantic_attestation`]).
269    pub fn transcript_digest(&self) -> [u8; 32] {
270        self.unsigned().transcript_digest()
271    }
272}
273
274/// The unsigned content of a [`ReadinessAttestation`] — exactly the
275/// fields the §4.2 signature transcript binds, as named fields so
276/// the three adjacent `u64`s (origin, generation, seq) can never be
277/// swapped silently at a call site. [`sign_attestation`] seals it.
278#[derive(Clone, PartialEq, Eq, Debug)]
279pub struct UnsignedAttestation {
280    /// See [`ReadinessAttestation::interest_digest`].
281    pub interest_digest: Digest256,
282    /// See [`ReadinessAttestation::origin`]; must match the signing
283    /// keypair's node id.
284    pub origin: u64,
285    /// See [`ReadinessAttestation::origin_incarnation`].
286    pub origin_incarnation: Incarnation,
287    /// See [`ReadinessAttestation::capability_id`].
288    pub capability_id: CapabilityId,
289    /// See [`ReadinessAttestation::capability_generation`].
290    pub capability_generation: u64,
291    /// See [`ReadinessAttestation::status`].
292    pub status: AttestedStatus,
293    /// See [`ReadinessAttestation::status_reason`].
294    pub status_reason: StatusReason,
295    /// See [`ReadinessAttestation::estimated_start`].
296    pub estimated_start: Option<Duration>,
297    /// See [`ReadinessAttestation::seq`].
298    pub seq: u64,
299    /// See [`ReadinessAttestation::promised_cadence`].
300    pub promised_cadence: Duration,
301    /// See [`ReadinessAttestation::audience_scope`].
302    pub audience_scope: AudienceScopeCommitment,
303}
304
305impl UnsignedAttestation {
306    /// The hand-rolled signature transcript (module docs) —
307    /// injective by construction: the single variable-width field
308    /// (`capability_id`) is length-prefixed; everything else is
309    /// fixed-width:
310    ///
311    /// ```text
312    /// interest_digest         32 B
313    /// origin                  u64 LE
314    /// origin_incarnation      u64 LE
315    /// len(capability_id)      u64 LE, then the UTF-8 bytes
316    /// capability_generation   u64 LE
317    /// status                  1 B canonical tag
318    /// status_reason           3 B (tag + u16 LE parameter)
319    /// estimated_start         1 B presence + u128 LE nanos
320    /// seq                     u64 LE
321    /// promised_cadence        u128 LE nanos
322    /// audience_scope          32 B
323    /// ```
324    ///
325    /// The protocol domain + version bind via the
326    /// [`ATTESTATION_SIG_DOMAIN`] derive-key context in
327    /// [`Self::transcript_digest`], not as transcript bytes.
328    pub fn transcript(&self) -> Vec<u8> {
329        let id_bytes = self.capability_id.as_str().as_bytes();
330        let mut out = Vec::with_capacity(128 + id_bytes.len());
331        out.extend_from_slice(self.interest_digest.as_bytes());
332        out.extend_from_slice(&self.origin.to_le_bytes());
333        out.extend_from_slice(&self.origin_incarnation.get().to_le_bytes());
334        out.extend_from_slice(&(id_bytes.len() as u64).to_le_bytes());
335        out.extend_from_slice(id_bytes);
336        out.extend_from_slice(&self.capability_generation.to_le_bytes());
337        out.push(status_tag(self.status));
338        out.extend_from_slice(&reason_bytes(self.status_reason));
339        match self.estimated_start {
340            None => out.extend_from_slice(&[0u8; 17]),
341            Some(estimate) => {
342                out.push(1);
343                out.extend_from_slice(&estimate.as_nanos().to_le_bytes());
344            }
345        }
346        out.extend_from_slice(&self.seq.to_le_bytes());
347        out.extend_from_slice(&self.promised_cadence.as_nanos().to_le_bytes());
348        out.extend_from_slice(self.audience_scope.as_bytes());
349        out
350    }
351
352    /// `blake3::derive_key(ATTESTATION_SIG_DOMAIN, transcript)` —
353    /// the 32 bytes the origin actually signs (module docs), and the
354    /// semantic fingerprint of the attestation.
355    pub fn transcript_digest(&self) -> [u8; 32] {
356        let mut hasher = blake3::Hasher::new_derive_key(ATTESTATION_SIG_DOMAIN);
357        hasher.update(&self.transcript());
358        *hasher.finalize().as_bytes()
359    }
360}
361
362/// Canonical 1-byte transcript tag for [`AttestedStatus`]
363/// (append-only; never a serde encoding).
364const fn status_tag(status: AttestedStatus) -> u8 {
365    match status {
366        AttestedStatus::Ready => 0,
367        AttestedStatus::NotReady => 1,
368        AttestedStatus::ProviderUnknown => 2,
369    }
370}
371
372/// Canonical fixed-width transcript encoding for [`StatusReason`]:
373/// variant tag + u16 LE parameter (zero where unused; append-only).
374const fn reason_bytes(reason: StatusReason) -> [u8; 3] {
375    match reason {
376        StatusReason::None => [0, 0, 0],
377        StatusReason::Provider(code) => {
378            let le = code.to_le_bytes();
379            [1, le[0], le[1]]
380        }
381        StatusReason::UnsupportedPredicate => [2, 0, 0],
382        StatusReason::TemporarilyUnevaluable => [3, 0, 0],
383        StatusReason::InvalidConstraints => [4, 0, 0],
384        StatusReason::SamplingIntervalUnsupported => [5, 0, 0],
385    }
386}
387
388/// Why an attestation could not be signed.
389#[derive(Clone, PartialEq, Eq, Debug)]
390pub enum AttestationSignError {
391    /// The unsigned `origin` does not name the signing keypair's
392    /// node id — an origin must only ever sign as itself.
393    OriginMismatch {
394        /// What the unsigned fields claimed.
395        claimed: u64,
396        /// The signing keypair's actual node id.
397        keypair: u64,
398    },
399    /// The keypair refused to sign (public-only keypairs return
400    /// [`EntityError::ReadOnly`]).
401    Signing(EntityError),
402}
403
404impl fmt::Display for AttestationSignError {
405    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406        match self {
407            Self::OriginMismatch { claimed, keypair } => write!(
408                f,
409                "attestation origin {claimed:#x} is not the signing keypair's node id \
410                 {keypair:#x}"
411            ),
412            Self::Signing(error) => write!(f, "attestation signing failed: {error}"),
413        }
414    }
415}
416
417impl std::error::Error for AttestationSignError {}
418
419/// Why an attestation failed verification.
420#[derive(Clone, PartialEq, Eq, Debug)]
421pub enum AttestationVerifyError {
422    /// The attested `origin` does not name the verifying entity's
423    /// node id — the proof is not this entity's, whatever the
424    /// signature says.
425    OriginMismatch {
426        /// What the attestation claimed.
427        attested: u64,
428        /// The verifying entity's actual node id.
429        entity: u64,
430    },
431    /// The signature does not verify over the re-built transcript
432    /// (or the entity's public key is invalid).
433    Signature(EntityError),
434}
435
436impl fmt::Display for AttestationVerifyError {
437    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
438        match self {
439            Self::OriginMismatch { attested, entity } => write!(
440                f,
441                "attestation origin {attested:#x} is not the verifying entity's node id \
442                 {entity:#x}"
443            ),
444            Self::Signature(error) => write!(f, "attestation signature invalid: {error}"),
445        }
446    }
447}
448
449impl std::error::Error for AttestationVerifyError {}
450
451/// Sign an attestation: `origin` must be the keypair's own node id
452/// (an origin only ever signs as itself), and the keypair must hold
453/// its signing half (`try_sign` — public-only keypairs fail closed,
454/// never panic). The signature covers the 32-byte transcript digest
455/// (module docs).
456pub fn sign_attestation(
457    keypair: &EntityKeypair,
458    unsigned: UnsignedAttestation,
459) -> Result<ReadinessAttestation, AttestationSignError> {
460    let keypair_node = keypair.node_id();
461    if unsigned.origin != keypair_node {
462        return Err(AttestationSignError::OriginMismatch {
463            claimed: unsigned.origin,
464            keypair: keypair_node,
465        });
466    }
467    let digest = unsigned.transcript_digest();
468    let signature = keypair
469        .try_sign(&digest)
470        .map_err(AttestationSignError::Signing)?;
471    let UnsignedAttestation {
472        interest_digest,
473        origin,
474        origin_incarnation,
475        capability_id,
476        capability_generation,
477        status,
478        status_reason,
479        estimated_start,
480        seq,
481        promised_cadence,
482        audience_scope,
483    } = unsigned;
484    Ok(ReadinessAttestation {
485        interest_digest,
486        origin,
487        origin_incarnation,
488        capability_id,
489        capability_generation,
490        status,
491        status_reason,
492        estimated_start,
493        seq,
494        promised_cadence,
495        audience_scope,
496        signature: Signature64(signature.to_bytes()),
497    })
498}
499
500/// Verify an attestation against the claimed origin's entity
501/// identity: the attested `origin` must be `origin_entity`'s node id
502/// AND the signature must verify (strict ed25519) over the re-built
503/// transcript digest. Any tampered transcript field changes the
504/// digest and fails here.
505pub fn verify_attestation(
506    attestation: &ReadinessAttestation,
507    origin_entity: &EntityId,
508) -> Result<(), AttestationVerifyError> {
509    let entity_node = origin_entity.node_id();
510    if attestation.origin != entity_node {
511        return Err(AttestationVerifyError::OriginMismatch {
512            attested: attestation.origin,
513            entity: entity_node,
514        });
515    }
516    origin_entity
517        .verify_bytes(&attestation.transcript_digest(), &attestation.signature.0)
518        .map_err(AttestationVerifyError::Signature)
519}
520
521/// Why a wire attestation could not be bridged onto a validated
522/// interest ([`semantic_attestation`]).
523#[derive(Clone, Copy, PartialEq, Eq, Debug)]
524pub enum AttestationBridgeError {
525    /// The attestation names a different capability than the
526    /// validated interest key.
527    CapabilityMismatch,
528    /// The attestation answers a different interest digest than the
529    /// validated interest key.
530    InterestDigestMismatch,
531}
532
533impl fmt::Display for AttestationBridgeError {
534    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
535        match self {
536            Self::CapabilityMismatch => {
537                f.write_str("attestation capability does not match the validated interest")
538            }
539            Self::InterestDigestMismatch => {
540                f.write_str("attestation interest digest does not match the validated interest")
541            }
542        }
543    }
544}
545
546impl std::error::Error for AttestationBridgeError {}
547
548/// Bridge a wire [`ReadinessAttestation`] into the semantic layer's
549/// [`Attestation`]. The caller supplies the VALIDATED
550/// [`CapabilityInterestKey`] — the one re-derived at frame intake
551/// ([`SensingInterestFrame::validated_spec`]), never one rebuilt
552/// from the attestation's own claims — and this helper cross-checks
553/// the attestation against it before minting the
554/// [`ProviderObservationKey`]. Verify authorship first
555/// ([`verify_attestation`]); the bridge maps identity, not trust.
556///
557/// The semantic `fingerprint` is the transcript digest — the SI-0
558/// stand-in (a hash of the semantic fields) is now the REAL
559/// signed-bytes digest, so seq-gate equivocation detection keys on
560/// exactly what the origin signed.
561pub fn semantic_attestation(
562    interest: &CapabilityInterestKey,
563    wire: &ReadinessAttestation,
564) -> Result<Attestation, AttestationBridgeError> {
565    if wire.capability_id != interest.capability_id {
566        return Err(AttestationBridgeError::CapabilityMismatch);
567    }
568    if wire.interest_digest != interest.interest_digest {
569        return Err(AttestationBridgeError::InterestDigestMismatch);
570    }
571    Ok(Attestation {
572        key: ProviderObservationKey::new(interest.clone(), wire.origin, wire.capability_generation),
573        origin_incarnation: wire.origin_incarnation,
574        status: wire.status,
575        estimated_start: wire.estimated_start,
576        seq: wire.seq,
577        promised_cadence: wire.promised_cadence,
578        fingerprint: Digest256::from_bytes(wire.transcript_digest()),
579    })
580}
581
582#[cfg(test)]
583mod tests {
584    use super::super::super::broadcast::{SUBPROTOCOL_CAPABILITY_ANN, SUBPROTOCOL_ROUTE_WITHDRAW};
585    use super::super::identity::{
586        CanonicalConstraints, DisclosureClass, InterestSpec, ProviderSelector, ResultMode,
587        WorkLatencyEnvelope,
588    };
589    use super::*;
590
591    fn spec() -> InterestSpec {
592        InterestSpec {
593            capability_id: CapabilityId::new("print.document"),
594            constraints: CanonicalConstraints::from_entries([("color", "true"), ("media", "a4")])
595                .unwrap(),
596            work_latency: WorkLatencyEnvelope::start_within(Duration::from_secs(5)),
597            providers: ProviderSelector::AnyAuthorized,
598            result_mode: ResultMode::Any,
599            disclosure_class: DisclosureClass::Owner,
600            audience: AudienceScopeCommitment::from_bytes([0xAA; 32]),
601        }
602    }
603
604    fn keypair() -> EntityKeypair {
605        EntityKeypair::from_bytes([7u8; 32])
606    }
607
608    fn unsigned(origin: u64) -> UnsignedAttestation {
609        UnsignedAttestation {
610            interest_digest: spec().interest_digest(),
611            origin,
612            origin_incarnation: Incarnation::new(3),
613            capability_id: CapabilityId::new("print.document"),
614            capability_generation: 12,
615            status: AttestedStatus::Ready,
616            status_reason: StatusReason::None,
617            estimated_start: Some(Duration::from_millis(800)),
618            seq: 41,
619            promised_cadence: Duration::from_millis(150),
620            audience_scope: AudienceScopeCommitment::from_bytes([0xAA; 32]),
621        }
622    }
623
624    fn signed() -> ReadinessAttestation {
625        let keypair = keypair();
626        sign_attestation(&keypair, unsigned(keypair.node_id())).unwrap()
627    }
628
629    #[test]
630    fn subprotocol_ids_are_committed_in_the_0x0c_family() {
631        // Committed per review-7 sign-off — moving either after this
632        // point is a wire break.
633        assert_eq!(SUBPROTOCOL_SENSING_INTEREST, 0x0C02);
634        assert_eq!(SUBPROTOCOL_READINESS_ATTESTATION, 0x0C03);
635        // Contiguous with, and distinct from, the existing family.
636        assert_eq!(SUBPROTOCOL_CAPABILITY_ANN, 0x0C00);
637        assert_eq!(SUBPROTOCOL_ROUTE_WITHDRAW, 0x0C01);
638    }
639
640    #[test]
641    fn interest_frames_round_trip_through_postcard() {
642        let spec = spec();
643        let frames = [
644            SensingInterestFrame::capability_registration(
645                &spec,
646                Duration::from_millis(100),
647                Duration::from_secs(30),
648                0xA11CE,
649            ),
650            SensingInterestFrame::provider_registration(
651                &spec,
652                0x77,
653                Duration::from_millis(100),
654                Duration::from_secs(30),
655            ),
656            SensingInterestFrame::Deregister {
657                interest_digest: spec.interest_digest(),
658                target: Some(0x77),
659            },
660            SensingInterestFrame::Deregister {
661                interest_digest: spec.interest_digest(),
662                target: None,
663            },
664        ];
665        for frame in frames {
666            let bytes = encode_interest_frame(&frame).unwrap();
667            assert!(bytes.len() <= MAX_SENSING_FRAME_BYTES);
668            assert_eq!(decode_interest_frame(&bytes).unwrap(), frame);
669        }
670    }
671
672    #[test]
673    fn strict_decode_rejects_trailing_truncated_and_oversize_frames() {
674        let frame = SensingInterestFrame::capability_registration(
675            &spec(),
676            Duration::from_millis(100),
677            Duration::from_secs(30),
678            0xA,
679        );
680        let bytes = encode_interest_frame(&frame).unwrap();
681
682        // Trailing bytes: exactly one object per payload.
683        let mut trailing = bytes.clone();
684        trailing.push(0);
685        assert_eq!(
686            decode_interest_frame(&trailing),
687            Err(WireError::TrailingBytes { remaining: 1 }),
688        );
689
690        // Every strict prefix is invalid (all fields mandatory).
691        for cut in 0..bytes.len() {
692            assert!(
693                decode_interest_frame(&bytes[..cut]).is_err(),
694                "truncation at {cut} must not decode",
695            );
696        }
697
698        // Oversize input is refused before parsing.
699        let oversize = vec![0u8; MAX_SENSING_FRAME_BYTES + 1];
700        assert_eq!(
701            decode_interest_frame(&oversize),
702            Err(WireError::Oversize {
703                len: MAX_SENSING_FRAME_BYTES + 1,
704            }),
705        );
706    }
707
708    #[test]
709    fn oversize_frames_are_refused_on_encode() {
710        // A Nodes selector big enough to blow the 4 KiB cap — the
711        // one unbounded field family the constraint cap does not
712        // already bound.
713        let mut huge = spec();
714        huge.providers =
715            ProviderSelector::nodes((0..600).map(|i| u64::MAX - i as u64).collect::<Vec<_>>());
716        let frame = SensingInterestFrame::capability_registration(
717            &huge,
718            Duration::from_millis(100),
719            Duration::from_secs(30),
720            0xA,
721        );
722        assert!(matches!(
723            encode_interest_frame(&frame),
724            Err(WireError::Oversize { .. }),
725        ));
726    }
727
728    #[test]
729    fn attestations_round_trip_and_still_verify() {
730        let attestation = signed();
731        let bytes = encode_attestation(&attestation).unwrap();
732        assert!(bytes.len() <= MAX_SENSING_FRAME_BYTES);
733        let back = decode_attestation(&bytes).unwrap();
734        assert_eq!(back, attestation);
735        // The codec never disturbs the transcript: verification
736        // still holds on the decoded copy.
737        verify_attestation(&back, keypair().entity_id()).unwrap();
738    }
739
740    #[test]
741    fn attestation_decode_rejects_trailing_and_truncation() {
742        let bytes = encode_attestation(&signed()).unwrap();
743        let mut trailing = bytes.clone();
744        trailing.push(0xFF);
745        assert_eq!(
746            decode_attestation(&trailing),
747            Err(WireError::TrailingBytes { remaining: 1 }),
748        );
749        for cut in 0..bytes.len() {
750            assert!(
751                decode_attestation(&bytes[..cut]).is_err(),
752                "truncation at {cut} must not decode",
753            );
754        }
755    }
756
757    #[test]
758    fn sign_and_verify_round_trip() {
759        let keypair = keypair();
760        let attestation = sign_attestation(&keypair, unsigned(keypair.node_id())).unwrap();
761        verify_attestation(&attestation, keypair.entity_id()).unwrap();
762        // The fingerprint surface: deterministic and equal between
763        // the unsigned and signed views of one attestation.
764        assert_eq!(
765            attestation.transcript_digest(),
766            unsigned(keypair.node_id()).transcript_digest(),
767        );
768    }
769
770    #[test]
771    fn signing_rejects_a_foreign_origin_and_a_public_only_keypair() {
772        let keypair = keypair();
773        // An origin must only ever sign as itself.
774        let foreign = unsigned(keypair.node_id() ^ 1);
775        assert_eq!(
776            sign_attestation(&keypair, foreign),
777            Err(AttestationSignError::OriginMismatch {
778                claimed: keypair.node_id() ^ 1,
779                keypair: keypair.node_id(),
780            }),
781        );
782        // Public-only keypairs fail closed via try_sign — no panic.
783        let public_only = EntityKeypair::public_only(keypair.entity_id().clone());
784        assert_eq!(
785            sign_attestation(&public_only, unsigned(keypair.node_id())),
786            Err(AttestationSignError::Signing(EntityError::ReadOnly)),
787        );
788    }
789
790    #[test]
791    fn verification_rejects_the_wrong_entity() {
792        let attestation = signed();
793        let other = EntityKeypair::from_bytes([9u8; 32]);
794        // The attested origin is not this entity's node id.
795        assert!(matches!(
796            verify_attestation(&attestation, other.entity_id()),
797            Err(AttestationVerifyError::OriginMismatch { .. }),
798        ));
799        // And an entity whose node id was forged to match still
800        // fails on the signature itself.
801        let mut forged = attestation.clone();
802        forged.origin = other.node_id();
803        assert!(matches!(
804            verify_attestation(&forged, other.entity_id()),
805            Err(AttestationVerifyError::Signature(_)),
806        ));
807    }
808
809    #[test]
810    fn every_transcript_field_is_tamper_evident() {
811        // Flip each signed field (and the signature) one at a time:
812        // verification must fail for every mutation — the transcript
813        // binds EXACTLY the §4.2 list, so nothing here is malleable.
814        type AttestationMutation = fn(&mut ReadinessAttestation);
815        let mutations: [(&str, AttestationMutation); 12] = [
816            ("interest_digest", |a| {
817                a.interest_digest = Digest256::from_bytes([0xFF; 32]);
818            }),
819            ("origin", |a| a.origin ^= 1),
820            ("origin_incarnation", |a| {
821                a.origin_incarnation = Incarnation::new(a.origin_incarnation.get() + 1);
822            }),
823            ("capability_id", |a| {
824                a.capability_id = CapabilityId::new("print.documenu");
825            }),
826            ("capability_generation", |a| a.capability_generation += 1),
827            ("status", |a| a.status = AttestedStatus::NotReady),
828            ("status_reason", |a| {
829                a.status_reason = StatusReason::Provider(7);
830            }),
831            ("estimated_start", |a| a.estimated_start = None),
832            ("seq", |a| a.seq += 1),
833            ("promised_cadence", |a| {
834                a.promised_cadence = Duration::from_millis(151);
835            }),
836            ("audience_scope", |a| {
837                a.audience_scope = AudienceScopeCommitment::from_bytes([0xBB; 32]);
838            }),
839            ("signature", |a| a.signature.0[0] ^= 1),
840        ];
841        let entity = keypair().entity_id().clone();
842        for (field, mutate) in mutations {
843            let mut tampered = signed();
844            mutate(&mut tampered);
845            assert!(
846                verify_attestation(&tampered, &entity).is_err(),
847                "tampered {field} must fail verification",
848            );
849        }
850        // Control: the untampered attestation verifies.
851        verify_attestation(&signed(), &entity).unwrap();
852    }
853
854    #[test]
855    fn transcript_encoding_is_injective_at_the_option_boundary() {
856        // Presence is transcript-bearing: `None` and `Some(0)` are
857        // different signed statements.
858        let keypair = keypair();
859        let mut none = unsigned(keypair.node_id());
860        none.estimated_start = None;
861        let mut zero = unsigned(keypair.node_id());
862        zero.estimated_start = Some(Duration::ZERO);
863        assert_ne!(none.transcript_digest(), zero.transcript_digest());
864        // And the digest is domain-separated from the interest
865        // digest machinery.
866        assert_ne!(
867            none.transcript_digest(),
868            *spec().interest_digest().as_bytes(),
869        );
870    }
871
872    #[test]
873    fn bridge_mints_the_semantic_attestation_from_the_validated_key() {
874        let attestation = signed();
875        let key = spec().key();
876        let semantic = semantic_attestation(&key, &attestation).unwrap();
877        assert_eq!(semantic.key.interest, key);
878        assert_eq!(semantic.key.provider, attestation.origin);
879        assert_eq!(
880            semantic.key.capability_generation,
881            attestation.capability_generation,
882        );
883        assert_eq!(semantic.origin_incarnation, attestation.origin_incarnation);
884        assert_eq!(semantic.status, attestation.status);
885        assert_eq!(semantic.estimated_start, attestation.estimated_start);
886        assert_eq!(semantic.seq, attestation.seq);
887        assert_eq!(semantic.promised_cadence, attestation.promised_cadence);
888        // The SI-0 fingerprint stand-in is now the REAL signed-bytes
889        // digest.
890        assert_eq!(
891            semantic.fingerprint,
892            Digest256::from_bytes(attestation.transcript_digest()),
893        );
894    }
895
896    #[test]
897    fn bridge_rejects_a_mismatched_interest() {
898        let attestation = signed();
899        // Different digest, same capability.
900        let mut other = spec();
901        other.result_mode = ResultMode::Each;
902        assert_eq!(
903            semantic_attestation(&other.key(), &attestation).unwrap_err(),
904            AttestationBridgeError::InterestDigestMismatch,
905        );
906        // Different capability entirely.
907        let mut foreign = spec();
908        foreign.capability_id = CapabilityId::new("scan.document");
909        assert_eq!(
910            semantic_attestation(&foreign.key(), &attestation).unwrap_err(),
911            AttestationBridgeError::CapabilityMismatch,
912        );
913    }
914}