use std::fmt;
use std::time::Duration;
use super::super::super::identity::{EntityError, EntityId, EntityKeypair};
use super::super::capability::Signature64;
use super::continuity::AttestedStatus;
use super::delivery::Attestation;
use super::evaluator::StatusReason;
use super::frames::SensingInterestFrame;
use super::identity::{
AudienceScopeCommitment, CapabilityId, CapabilityInterestKey, Digest256, ProviderObservationKey,
};
use super::incarnation::Incarnation;
pub const SUBPROTOCOL_SENSING_INTEREST: u16 = 0x0C02;
pub const SUBPROTOCOL_READINESS_ATTESTATION: u16 = 0x0C03;
pub const SENSING_PROVISIONAL_STREAM: u64 = 0x0001_0C03;
pub const MAX_SENSING_FRAME_BYTES: usize = 4096;
pub const ATTESTATION_SIG_DOMAIN: &str = "net.sensing.attestation.sig.v1";
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum WireError {
Oversize {
len: usize,
},
Codec(postcard::Error),
TrailingBytes {
remaining: usize,
},
}
impl fmt::Display for WireError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Oversize { len } => {
write!(
f,
"sensing payload {len} B > {MAX_SENSING_FRAME_BYTES} B cap"
)
}
Self::Codec(error) => write!(f, "sensing payload codec failure: {error}"),
Self::TrailingBytes { remaining } => {
write!(f, "{remaining} trailing bytes after sensing payload")
}
}
}
}
impl std::error::Error for WireError {}
fn encode_capped<T: serde::Serialize>(value: &T) -> Result<Vec<u8>, WireError> {
let bytes = postcard::to_allocvec(value).map_err(WireError::Codec)?;
if bytes.len() > MAX_SENSING_FRAME_BYTES {
return Err(WireError::Oversize { len: bytes.len() });
}
Ok(bytes)
}
fn decode_strict<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, WireError> {
if bytes.len() > MAX_SENSING_FRAME_BYTES {
return Err(WireError::Oversize { len: bytes.len() });
}
let (value, rest) = postcard::take_from_bytes::<T>(bytes).map_err(WireError::Codec)?;
if !rest.is_empty() {
return Err(WireError::TrailingBytes {
remaining: rest.len(),
});
}
Ok(value)
}
pub fn encode_interest_frame(frame: &SensingInterestFrame) -> Result<Vec<u8>, WireError> {
encode_capped(frame)
}
pub fn decode_interest_frame(bytes: &[u8]) -> Result<SensingInterestFrame, WireError> {
decode_strict(bytes)
}
pub fn encode_attestation(attestation: &ReadinessAttestation) -> Result<Vec<u8>, WireError> {
encode_capped(attestation)
}
pub fn decode_attestation(bytes: &[u8]) -> Result<ReadinessAttestation, WireError> {
decode_strict(bytes)
}
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct ReadinessAttestation {
pub interest_digest: Digest256,
pub origin: u64,
pub origin_incarnation: Incarnation,
pub capability_id: CapabilityId,
pub capability_generation: u64,
pub status: AttestedStatus,
pub status_reason: StatusReason,
pub estimated_start: Option<Duration>,
pub seq: u64,
pub promised_cadence: Duration,
pub audience_scope: AudienceScopeCommitment,
pub signature: Signature64,
}
impl ReadinessAttestation {
pub fn unsigned(&self) -> UnsignedAttestation {
UnsignedAttestation {
interest_digest: self.interest_digest,
origin: self.origin,
origin_incarnation: self.origin_incarnation,
capability_id: self.capability_id.clone(),
capability_generation: self.capability_generation,
status: self.status,
status_reason: self.status_reason,
estimated_start: self.estimated_start,
seq: self.seq,
promised_cadence: self.promised_cadence,
audience_scope: self.audience_scope,
}
}
pub fn transcript_digest(&self) -> [u8; 32] {
self.unsigned().transcript_digest()
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct UnsignedAttestation {
pub interest_digest: Digest256,
pub origin: u64,
pub origin_incarnation: Incarnation,
pub capability_id: CapabilityId,
pub capability_generation: u64,
pub status: AttestedStatus,
pub status_reason: StatusReason,
pub estimated_start: Option<Duration>,
pub seq: u64,
pub promised_cadence: Duration,
pub audience_scope: AudienceScopeCommitment,
}
impl UnsignedAttestation {
pub fn transcript(&self) -> Vec<u8> {
let id_bytes = self.capability_id.as_str().as_bytes();
let mut out = Vec::with_capacity(128 + id_bytes.len());
out.extend_from_slice(self.interest_digest.as_bytes());
out.extend_from_slice(&self.origin.to_le_bytes());
out.extend_from_slice(&self.origin_incarnation.get().to_le_bytes());
out.extend_from_slice(&(id_bytes.len() as u64).to_le_bytes());
out.extend_from_slice(id_bytes);
out.extend_from_slice(&self.capability_generation.to_le_bytes());
out.push(status_tag(self.status));
out.extend_from_slice(&reason_bytes(self.status_reason));
match self.estimated_start {
None => out.extend_from_slice(&[0u8; 17]),
Some(estimate) => {
out.push(1);
out.extend_from_slice(&estimate.as_nanos().to_le_bytes());
}
}
out.extend_from_slice(&self.seq.to_le_bytes());
out.extend_from_slice(&self.promised_cadence.as_nanos().to_le_bytes());
out.extend_from_slice(self.audience_scope.as_bytes());
out
}
pub fn transcript_digest(&self) -> [u8; 32] {
let mut hasher = blake3::Hasher::new_derive_key(ATTESTATION_SIG_DOMAIN);
hasher.update(&self.transcript());
*hasher.finalize().as_bytes()
}
}
const fn status_tag(status: AttestedStatus) -> u8 {
match status {
AttestedStatus::Ready => 0,
AttestedStatus::NotReady => 1,
AttestedStatus::ProviderUnknown => 2,
}
}
const fn reason_bytes(reason: StatusReason) -> [u8; 3] {
match reason {
StatusReason::None => [0, 0, 0],
StatusReason::Provider(code) => {
let le = code.to_le_bytes();
[1, le[0], le[1]]
}
StatusReason::UnsupportedPredicate => [2, 0, 0],
StatusReason::TemporarilyUnevaluable => [3, 0, 0],
StatusReason::InvalidConstraints => [4, 0, 0],
StatusReason::SamplingIntervalUnsupported => [5, 0, 0],
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum AttestationSignError {
OriginMismatch {
claimed: u64,
keypair: u64,
},
Signing(EntityError),
}
impl fmt::Display for AttestationSignError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OriginMismatch { claimed, keypair } => write!(
f,
"attestation origin {claimed:#x} is not the signing keypair's node id \
{keypair:#x}"
),
Self::Signing(error) => write!(f, "attestation signing failed: {error}"),
}
}
}
impl std::error::Error for AttestationSignError {}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum AttestationVerifyError {
OriginMismatch {
attested: u64,
entity: u64,
},
Signature(EntityError),
}
impl fmt::Display for AttestationVerifyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OriginMismatch { attested, entity } => write!(
f,
"attestation origin {attested:#x} is not the verifying entity's node id \
{entity:#x}"
),
Self::Signature(error) => write!(f, "attestation signature invalid: {error}"),
}
}
}
impl std::error::Error for AttestationVerifyError {}
pub fn sign_attestation(
keypair: &EntityKeypair,
unsigned: UnsignedAttestation,
) -> Result<ReadinessAttestation, AttestationSignError> {
let keypair_node = keypair.node_id();
if unsigned.origin != keypair_node {
return Err(AttestationSignError::OriginMismatch {
claimed: unsigned.origin,
keypair: keypair_node,
});
}
let digest = unsigned.transcript_digest();
let signature = keypair
.try_sign(&digest)
.map_err(AttestationSignError::Signing)?;
let UnsignedAttestation {
interest_digest,
origin,
origin_incarnation,
capability_id,
capability_generation,
status,
status_reason,
estimated_start,
seq,
promised_cadence,
audience_scope,
} = unsigned;
Ok(ReadinessAttestation {
interest_digest,
origin,
origin_incarnation,
capability_id,
capability_generation,
status,
status_reason,
estimated_start,
seq,
promised_cadence,
audience_scope,
signature: Signature64(signature.to_bytes()),
})
}
pub fn verify_attestation(
attestation: &ReadinessAttestation,
origin_entity: &EntityId,
) -> Result<(), AttestationVerifyError> {
let entity_node = origin_entity.node_id();
if attestation.origin != entity_node {
return Err(AttestationVerifyError::OriginMismatch {
attested: attestation.origin,
entity: entity_node,
});
}
origin_entity
.verify_bytes(&attestation.transcript_digest(), &attestation.signature.0)
.map_err(AttestationVerifyError::Signature)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum AttestationBridgeError {
CapabilityMismatch,
InterestDigestMismatch,
}
impl fmt::Display for AttestationBridgeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CapabilityMismatch => {
f.write_str("attestation capability does not match the validated interest")
}
Self::InterestDigestMismatch => {
f.write_str("attestation interest digest does not match the validated interest")
}
}
}
}
impl std::error::Error for AttestationBridgeError {}
pub fn semantic_attestation(
interest: &CapabilityInterestKey,
wire: &ReadinessAttestation,
) -> Result<Attestation, AttestationBridgeError> {
if wire.capability_id != interest.capability_id {
return Err(AttestationBridgeError::CapabilityMismatch);
}
if wire.interest_digest != interest.interest_digest {
return Err(AttestationBridgeError::InterestDigestMismatch);
}
Ok(Attestation {
key: ProviderObservationKey::new(interest.clone(), wire.origin, wire.capability_generation),
origin_incarnation: wire.origin_incarnation,
status: wire.status,
estimated_start: wire.estimated_start,
seq: wire.seq,
promised_cadence: wire.promised_cadence,
fingerprint: Digest256::from_bytes(wire.transcript_digest()),
})
}
#[cfg(test)]
mod tests {
use super::super::super::broadcast::{SUBPROTOCOL_CAPABILITY_ANN, SUBPROTOCOL_ROUTE_WITHDRAW};
use super::super::identity::{
CanonicalConstraints, DisclosureClass, InterestSpec, ProviderSelector, ResultMode,
WorkLatencyEnvelope,
};
use super::*;
fn spec() -> InterestSpec {
InterestSpec {
capability_id: CapabilityId::new("print.document"),
constraints: CanonicalConstraints::from_entries([("color", "true"), ("media", "a4")])
.unwrap(),
work_latency: WorkLatencyEnvelope::start_within(Duration::from_secs(5)),
providers: ProviderSelector::AnyAuthorized,
result_mode: ResultMode::Any,
disclosure_class: DisclosureClass::Owner,
audience: AudienceScopeCommitment::from_bytes([0xAA; 32]),
}
}
fn keypair() -> EntityKeypair {
EntityKeypair::from_bytes([7u8; 32])
}
fn unsigned(origin: u64) -> UnsignedAttestation {
UnsignedAttestation {
interest_digest: spec().interest_digest(),
origin,
origin_incarnation: Incarnation::new(3),
capability_id: CapabilityId::new("print.document"),
capability_generation: 12,
status: AttestedStatus::Ready,
status_reason: StatusReason::None,
estimated_start: Some(Duration::from_millis(800)),
seq: 41,
promised_cadence: Duration::from_millis(150),
audience_scope: AudienceScopeCommitment::from_bytes([0xAA; 32]),
}
}
fn signed() -> ReadinessAttestation {
let keypair = keypair();
sign_attestation(&keypair, unsigned(keypair.node_id())).unwrap()
}
#[test]
fn subprotocol_ids_are_committed_in_the_0x0c_family() {
assert_eq!(SUBPROTOCOL_SENSING_INTEREST, 0x0C02);
assert_eq!(SUBPROTOCOL_READINESS_ATTESTATION, 0x0C03);
assert_eq!(SUBPROTOCOL_CAPABILITY_ANN, 0x0C00);
assert_eq!(SUBPROTOCOL_ROUTE_WITHDRAW, 0x0C01);
}
#[test]
fn interest_frames_round_trip_through_postcard() {
let spec = spec();
let frames = [
SensingInterestFrame::capability_registration(
&spec,
Duration::from_millis(100),
Duration::from_secs(30),
0xA11CE,
),
SensingInterestFrame::provider_registration(
&spec,
0x77,
Duration::from_millis(100),
Duration::from_secs(30),
),
SensingInterestFrame::Deregister {
interest_digest: spec.interest_digest(),
target: Some(0x77),
},
SensingInterestFrame::Deregister {
interest_digest: spec.interest_digest(),
target: None,
},
];
for frame in frames {
let bytes = encode_interest_frame(&frame).unwrap();
assert!(bytes.len() <= MAX_SENSING_FRAME_BYTES);
assert_eq!(decode_interest_frame(&bytes).unwrap(), frame);
}
}
#[test]
fn strict_decode_rejects_trailing_truncated_and_oversize_frames() {
let frame = SensingInterestFrame::capability_registration(
&spec(),
Duration::from_millis(100),
Duration::from_secs(30),
0xA,
);
let bytes = encode_interest_frame(&frame).unwrap();
let mut trailing = bytes.clone();
trailing.push(0);
assert_eq!(
decode_interest_frame(&trailing),
Err(WireError::TrailingBytes { remaining: 1 }),
);
for cut in 0..bytes.len() {
assert!(
decode_interest_frame(&bytes[..cut]).is_err(),
"truncation at {cut} must not decode",
);
}
let oversize = vec![0u8; MAX_SENSING_FRAME_BYTES + 1];
assert_eq!(
decode_interest_frame(&oversize),
Err(WireError::Oversize {
len: MAX_SENSING_FRAME_BYTES + 1,
}),
);
}
#[test]
fn oversize_frames_are_refused_on_encode() {
let mut huge = spec();
huge.providers =
ProviderSelector::nodes((0..600).map(|i| u64::MAX - i as u64).collect::<Vec<_>>());
let frame = SensingInterestFrame::capability_registration(
&huge,
Duration::from_millis(100),
Duration::from_secs(30),
0xA,
);
assert!(matches!(
encode_interest_frame(&frame),
Err(WireError::Oversize { .. }),
));
}
#[test]
fn attestations_round_trip_and_still_verify() {
let attestation = signed();
let bytes = encode_attestation(&attestation).unwrap();
assert!(bytes.len() <= MAX_SENSING_FRAME_BYTES);
let back = decode_attestation(&bytes).unwrap();
assert_eq!(back, attestation);
verify_attestation(&back, keypair().entity_id()).unwrap();
}
#[test]
fn attestation_decode_rejects_trailing_and_truncation() {
let bytes = encode_attestation(&signed()).unwrap();
let mut trailing = bytes.clone();
trailing.push(0xFF);
assert_eq!(
decode_attestation(&trailing),
Err(WireError::TrailingBytes { remaining: 1 }),
);
for cut in 0..bytes.len() {
assert!(
decode_attestation(&bytes[..cut]).is_err(),
"truncation at {cut} must not decode",
);
}
}
#[test]
fn sign_and_verify_round_trip() {
let keypair = keypair();
let attestation = sign_attestation(&keypair, unsigned(keypair.node_id())).unwrap();
verify_attestation(&attestation, keypair.entity_id()).unwrap();
assert_eq!(
attestation.transcript_digest(),
unsigned(keypair.node_id()).transcript_digest(),
);
}
#[test]
fn signing_rejects_a_foreign_origin_and_a_public_only_keypair() {
let keypair = keypair();
let foreign = unsigned(keypair.node_id() ^ 1);
assert_eq!(
sign_attestation(&keypair, foreign),
Err(AttestationSignError::OriginMismatch {
claimed: keypair.node_id() ^ 1,
keypair: keypair.node_id(),
}),
);
let public_only = EntityKeypair::public_only(keypair.entity_id().clone());
assert_eq!(
sign_attestation(&public_only, unsigned(keypair.node_id())),
Err(AttestationSignError::Signing(EntityError::ReadOnly)),
);
}
#[test]
fn verification_rejects_the_wrong_entity() {
let attestation = signed();
let other = EntityKeypair::from_bytes([9u8; 32]);
assert!(matches!(
verify_attestation(&attestation, other.entity_id()),
Err(AttestationVerifyError::OriginMismatch { .. }),
));
let mut forged = attestation.clone();
forged.origin = other.node_id();
assert!(matches!(
verify_attestation(&forged, other.entity_id()),
Err(AttestationVerifyError::Signature(_)),
));
}
#[test]
fn every_transcript_field_is_tamper_evident() {
type AttestationMutation = fn(&mut ReadinessAttestation);
let mutations: [(&str, AttestationMutation); 12] = [
("interest_digest", |a| {
a.interest_digest = Digest256::from_bytes([0xFF; 32]);
}),
("origin", |a| a.origin ^= 1),
("origin_incarnation", |a| {
a.origin_incarnation = Incarnation::new(a.origin_incarnation.get() + 1);
}),
("capability_id", |a| {
a.capability_id = CapabilityId::new("print.documenu");
}),
("capability_generation", |a| a.capability_generation += 1),
("status", |a| a.status = AttestedStatus::NotReady),
("status_reason", |a| {
a.status_reason = StatusReason::Provider(7);
}),
("estimated_start", |a| a.estimated_start = None),
("seq", |a| a.seq += 1),
("promised_cadence", |a| {
a.promised_cadence = Duration::from_millis(151);
}),
("audience_scope", |a| {
a.audience_scope = AudienceScopeCommitment::from_bytes([0xBB; 32]);
}),
("signature", |a| a.signature.0[0] ^= 1),
];
let entity = keypair().entity_id().clone();
for (field, mutate) in mutations {
let mut tampered = signed();
mutate(&mut tampered);
assert!(
verify_attestation(&tampered, &entity).is_err(),
"tampered {field} must fail verification",
);
}
verify_attestation(&signed(), &entity).unwrap();
}
#[test]
fn transcript_encoding_is_injective_at_the_option_boundary() {
let keypair = keypair();
let mut none = unsigned(keypair.node_id());
none.estimated_start = None;
let mut zero = unsigned(keypair.node_id());
zero.estimated_start = Some(Duration::ZERO);
assert_ne!(none.transcript_digest(), zero.transcript_digest());
assert_ne!(
none.transcript_digest(),
*spec().interest_digest().as_bytes(),
);
}
#[test]
fn bridge_mints_the_semantic_attestation_from_the_validated_key() {
let attestation = signed();
let key = spec().key();
let semantic = semantic_attestation(&key, &attestation).unwrap();
assert_eq!(semantic.key.interest, key);
assert_eq!(semantic.key.provider, attestation.origin);
assert_eq!(
semantic.key.capability_generation,
attestation.capability_generation,
);
assert_eq!(semantic.origin_incarnation, attestation.origin_incarnation);
assert_eq!(semantic.status, attestation.status);
assert_eq!(semantic.estimated_start, attestation.estimated_start);
assert_eq!(semantic.seq, attestation.seq);
assert_eq!(semantic.promised_cadence, attestation.promised_cadence);
assert_eq!(
semantic.fingerprint,
Digest256::from_bytes(attestation.transcript_digest()),
);
}
#[test]
fn bridge_rejects_a_mismatched_interest() {
let attestation = signed();
let mut other = spec();
other.result_mode = ResultMode::Each;
assert_eq!(
semantic_attestation(&other.key(), &attestation).unwrap_err(),
AttestationBridgeError::InterestDigestMismatch,
);
let mut foreign = spec();
foreign.capability_id = CapabilityId::new("scan.document");
assert_eq!(
semantic_attestation(&foreign.key(), &attestation).unwrap_err(),
AttestationBridgeError::CapabilityMismatch,
);
}
}