1use 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
68pub const SUBPROTOCOL_SENSING_INTEREST: u16 = 0x0C02;
81
82pub const SUBPROTOCOL_READINESS_ATTESTATION: u16 = 0x0C03;
86
87pub const SENSING_PROVISIONAL_STREAM: u64 = 0x0001_0C03;
102
103pub const MAX_SENSING_FRAME_BYTES: usize = 4096;
108
109pub const ATTESTATION_SIG_DOMAIN: &str = "net.sensing.attestation.sig.v1";
116
117#[derive(Clone, PartialEq, Eq, Debug)]
119pub enum WireError {
120 Oversize {
123 len: usize,
125 },
126 Codec(postcard::Error),
128 TrailingBytes {
131 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
176pub fn encode_interest_frame(frame: &SensingInterestFrame) -> Result<Vec<u8>, WireError> {
178 encode_capped(frame)
179}
180
181pub fn decode_interest_frame(bytes: &[u8]) -> Result<SensingInterestFrame, WireError> {
186 decode_strict(bytes)
187}
188
189pub fn encode_attestation(attestation: &ReadinessAttestation) -> Result<Vec<u8>, WireError> {
191 encode_capped(attestation)
192}
193
194pub fn decode_attestation(bytes: &[u8]) -> Result<ReadinessAttestation, WireError> {
198 decode_strict(bytes)
199}
200
201#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
208pub struct ReadinessAttestation {
209 pub interest_digest: Digest256,
213 pub origin: u64,
216 pub origin_incarnation: Incarnation,
218 pub capability_id: CapabilityId,
220 pub capability_generation: u64,
224 pub status: AttestedStatus,
226 pub status_reason: StatusReason,
228 pub estimated_start: Option<Duration>,
232 pub seq: u64,
235 pub promised_cadence: Duration,
238 pub audience_scope: AudienceScopeCommitment,
242 pub signature: Signature64,
245}
246
247impl ReadinessAttestation {
248 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 pub fn transcript_digest(&self) -> [u8; 32] {
270 self.unsigned().transcript_digest()
271 }
272}
273
274#[derive(Clone, PartialEq, Eq, Debug)]
279pub struct UnsignedAttestation {
280 pub interest_digest: Digest256,
282 pub origin: u64,
285 pub origin_incarnation: Incarnation,
287 pub capability_id: CapabilityId,
289 pub capability_generation: u64,
291 pub status: AttestedStatus,
293 pub status_reason: StatusReason,
295 pub estimated_start: Option<Duration>,
297 pub seq: u64,
299 pub promised_cadence: Duration,
301 pub audience_scope: AudienceScopeCommitment,
303}
304
305impl UnsignedAttestation {
306 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 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
362const fn status_tag(status: AttestedStatus) -> u8 {
365 match status {
366 AttestedStatus::Ready => 0,
367 AttestedStatus::NotReady => 1,
368 AttestedStatus::ProviderUnknown => 2,
369 }
370}
371
372const 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#[derive(Clone, PartialEq, Eq, Debug)]
390pub enum AttestationSignError {
391 OriginMismatch {
394 claimed: u64,
396 keypair: u64,
398 },
399 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#[derive(Clone, PartialEq, Eq, Debug)]
421pub enum AttestationVerifyError {
422 OriginMismatch {
426 attested: u64,
428 entity: u64,
430 },
431 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
451pub 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
500pub 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
524pub enum AttestationBridgeError {
525 CapabilityMismatch,
528 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
548pub 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 assert_eq!(SUBPROTOCOL_SENSING_INTEREST, 0x0C02);
634 assert_eq!(SUBPROTOCOL_READINESS_ATTESTATION, 0x0C03);
635 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 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 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 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 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 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 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 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 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 assert!(matches!(
796 verify_attestation(&attestation, other.entity_id()),
797 Err(AttestationVerifyError::OriginMismatch { .. }),
798 ));
799 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 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 verify_attestation(&signed(), &entity).unwrap();
852 }
853
854 #[test]
855 fn transcript_encoding_is_injective_at_the_option_boundary() {
856 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 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 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 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 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}