//! Peer tickets: self-contained connection invitations. use std::fmt; use std::str::FromStr; use iroh::{EndpointAddr, EndpointId}; use iroh_tickets::{ParseError, Ticket}; use serde::{Deserialize, Serialize}; use crate::protocol::{NetworkId, SchemaId}; /// Current version of the ticket wire format. pub const TICKET_VERSION: u16 = 1; /// Maximum size of a decoded ticket in bytes. const MAX_TICKET_BYTES: usize = 8 * 1024; /// Maximum length of a ticket string accepted by [`PeerTicket::from_str`]. const MAX_TICKET_STRING_LEN: usize = 16 * 1024; /// A shareable invitation to connect to a peer. /// /// The ticket contains everything needed to dial the peer over Iroh, plus the /// network and schema identifiers so incompatibility is detected before any /// domain message is exchanged. /// /// Tickets serialize to a string with the `fnet` prefix via [`fmt::Display`] /// and parse back via [`FromStr`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PeerTicket { /// Version of the ticket format itself. pub ticket_version: u16, /// Version of the application-level wire protocol the peer speaks. pub protocol_version: u16, /// Network the issuing peer participates in. pub network_id: NetworkId, /// Message schema the issuing peer uses. pub schema_id: SchemaId, /// Iroh address of the issuing peer. pub endpoint_addr: EndpointAddr, } impl PeerTicket { /// Returns the endpoint id of the peer this ticket points to. pub fn endpoint_id(&self) -> EndpointId { self.endpoint_addr.id } } /// Versioned body of the ticket; everything after the leading version number. #[derive(Serialize, Deserialize)] struct TicketBody { protocol_version: u16, network_id: NetworkId, schema_id: SchemaId, endpoint_addr: EndpointAddr, } impl Ticket for PeerTicket { const KIND: &'static str = "fnet"; fn encode_bytes(&self) -> Vec { let body = TicketBody { protocol_version: self.protocol_version, network_id: self.network_id, schema_id: self.schema_id, endpoint_addr: self.endpoint_addr.clone(), }; // Serializing plain owned data into a growable Vec is infallible; // postcard only errors on unsupported types or fixed-size buffers. postcard::to_stdvec(&(self.ticket_version, body)) .expect("postcard serialization of a ticket into a Vec cannot fail") } fn decode_bytes(bytes: &[u8]) -> Result { if bytes.len() > MAX_TICKET_BYTES { return Err(ParseError::verification_failed( "ticket exceeds the maximum allowed size", )); } let (ticket_version, rest) = postcard::take_from_bytes::(bytes)?; if ticket_version != TICKET_VERSION { return Err(ParseError::verification_failed( "unsupported ticket version", )); } let body: TicketBody = postcard::from_bytes(rest)?; Ok(Self { ticket_version, protocol_version: body.protocol_version, network_id: body.network_id, schema_id: body.schema_id, endpoint_addr: body.endpoint_addr, }) } } impl fmt::Display for PeerTicket { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&Ticket::encode_string(self)) } } impl FromStr for PeerTicket { type Err = ParseError; fn from_str(s: &str) -> Result { if s.len() > MAX_TICKET_STRING_LEN { return Err(ParseError::verification_failed( "ticket string exceeds the maximum allowed length", )); } Ticket::decode_string(s) } } #[cfg(test)] mod tests { use std::net::{Ipv4Addr, SocketAddr}; use iroh::SecretKey; use super::*; fn sample_ticket() -> PeerTicket { let endpoint_id = SecretKey::generate().public(); let addr = EndpointAddr::new(endpoint_id) .with_ip_addr(SocketAddr::from((Ipv4Addr::LOCALHOST, 4242))); PeerTicket { ticket_version: TICKET_VERSION, protocol_version: 1, network_id: NetworkId::from_name("test-network"), schema_id: SchemaId::from_name("test-schema"), endpoint_addr: addr, } } #[test] fn ticket_string_round_trip() { let ticket = sample_ticket(); let encoded = ticket.to_string(); assert!(encoded.starts_with("fnet")); let decoded: PeerTicket = encoded.parse().expect("parse ticket"); assert_eq!(decoded, ticket); // The canonical string form must be stable across round-trips. assert_eq!(decoded.to_string(), encoded); } #[test] fn corrupted_ticket_is_rejected() { let ticket = sample_ticket(); let mut encoded = ticket.to_string(); // Truncate the payload; the result must not parse. encoded.truncate(encoded.len() - 10); assert!(encoded.parse::().is_err()); // Corrupt the alphabet: '!' is not valid base32. let corrupted = format!("fnet!{}", &ticket.to_string()[5..]); assert!(corrupted.parse::().is_err()); } #[test] fn wrong_prefix_is_rejected() { let ticket = sample_ticket(); let encoded = ticket.to_string(); let renamed = format!("blob{}", &encoded[4..]); assert!(renamed.parse::().is_err()); } #[test] fn unknown_ticket_version_is_rejected() { let ticket = sample_ticket(); let body = TicketBody { protocol_version: ticket.protocol_version, network_id: ticket.network_id, schema_id: ticket.schema_id, endpoint_addr: ticket.endpoint_addr.clone(), }; let bytes = postcard::to_stdvec(&(99u16, body)).expect("encode"); let mut encoded = String::from("fnet"); data_encoding::BASE32_NOPAD.encode_append(&bytes, &mut encoded); encoded.make_ascii_lowercase(); assert!(encoded.parse::().is_err()); } #[test] fn oversized_ticket_is_rejected() { let bytes = vec![0u8; MAX_TICKET_BYTES + 1]; assert!(PeerTicket::decode_bytes(&bytes).is_err()); let huge = "fnet".to_string() + &"a".repeat(MAX_TICKET_STRING_LEN); assert!(huge.parse::().is_err()); } }