Skip to main content

openrtc/
ticket_mesh.rs

1//! The transferable invitation format for a ticket-scoped peer mesh.
2//!
3//! Parsing is deliberately strict. The compact endpoint grant is the bearer
4//! admission proof; the outer fields only describe the ticket avenue and must
5//! never be used as independent admission authority.
6
7use std::collections::BTreeMap;
8use std::fmt;
9use std::str::FromStr;
10use std::time::Duration;
11
12use anyhow::{bail, Result};
13use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
14use iroh_tickets::endpoint::EndpointTicket;
15use serde::{Deserialize, Serialize};
16
17use crate::session_token::{decode_payload, split_ticket, TOKEN_PAYLOAD_ENDPOINT_TICKET_AUDIENCE};
18use crate::Client;
19
20#[cfg(not(target_arch = "wasm32"))]
21mod session;
22#[cfg(not(target_arch = "wasm32"))]
23pub use session::TicketMesh;
24#[cfg(target_arch = "wasm32")]
25pub(crate) mod wasm_session;
26
27const INVITE_PREFIX: &str = "openrtc-ticket-v1.";
28const MAX_INVITE_BYTES: usize = 16 * 1024;
29const MAX_MESH_PEERS: u32 = 8;
30pub(crate) const WIRE_PREFIX: &[u8] = b"openrtc.ticket.mesh.v1\0";
31
32// Native and browser actors implement different I/O edges but share one mesh
33// lifecycle policy. Keep every portable lease, retry, and pruning interval in
34// this module so the two runtime projections cannot silently diverge.
35pub(crate) const ROSTER_RESEND: Duration = Duration::from_secs(15);
36pub(crate) const HELLO_RETRY: Duration = Duration::from_secs(3);
37pub(crate) const GUEST_GRACE: Duration = Duration::from_secs(45);
38pub(crate) const MAX_RETRY: Duration = Duration::from_secs(10);
39pub(crate) const ADMISSION_LEASE: Duration = Duration::from_secs(90);
40pub(crate) const ADMISSION_REFRESH: Duration = Duration::from_secs(30);
41pub(crate) const INVITE_REFRESH: Duration = Duration::from_secs(10 * 60);
42pub(crate) const INVITE_REFRESH_RETRY: Duration = Duration::from_secs(30);
43
44/// Stable machine-readable failures from the public ticket-mesh surface.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum TicketMeshErrorCode {
47    InvalidInvitation,
48    InvalidOptions,
49    CapacityExceeded,
50    AlreadyActive,
51    Unavailable,
52}
53
54impl TicketMeshErrorCode {
55    pub const fn as_str(self) -> &'static str {
56        match self {
57            Self::InvalidInvitation => "admission/ticket-invite-invalid",
58            Self::InvalidOptions => "admission/ticket-mesh-invalid-options",
59            Self::CapacityExceeded => "admission/ticket-mesh-capacity-exceeded",
60            Self::AlreadyActive => "admission/ticket-mesh-already-active",
61            Self::Unavailable => "coordination/ticket-mesh-unavailable",
62        }
63    }
64}
65
66impl fmt::Display for TicketMeshErrorCode {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        formatter.write_str(self.as_str())
69    }
70}
71
72/// Typed ticket-mesh error retained inside the crate's `anyhow::Error` result.
73/// Callers can downcast and switch on `code()` without matching message text.
74#[derive(Debug)]
75pub struct TicketMeshError {
76    code: TicketMeshErrorCode,
77    message: String,
78}
79
80impl TicketMeshError {
81    pub fn new(code: TicketMeshErrorCode, message: impl Into<String>) -> Self {
82        Self {
83            code,
84            message: message.into(),
85        }
86    }
87
88    pub const fn code(&self) -> TicketMeshErrorCode {
89        self.code
90    }
91}
92
93impl fmt::Display for TicketMeshError {
94    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95        write!(formatter, "{}: {}", self.code, self.message)
96    }
97}
98
99impl std::error::Error for TicketMeshError {}
100
101fn mesh_error(code: TicketMeshErrorCode, message: impl Into<String>) -> anyhow::Error {
102    TicketMeshError::new(code, message).into()
103}
104
105fn mesh_owner_error(message: String) -> anyhow::Error {
106    let code = if message.contains("already has an issuer owner") {
107        TicketMeshErrorCode::AlreadyActive
108    } else if message.contains("invalid ticket participant limit") {
109        TicketMeshErrorCode::InvalidOptions
110    } else {
111        TicketMeshErrorCode::Unavailable
112    };
113    mesh_error(code, message)
114}
115
116#[derive(Serialize, Deserialize)]
117#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
118pub(crate) enum Wire {
119    Hello {
120        id: String,
121        ticket: String,
122    },
123    Recovery {
124        id: String,
125        ticket: String,
126    },
127    Roster {
128        id: String,
129        revision: u64,
130        members: Vec<TicketMeshMember>,
131        issuer_invite: String,
132    },
133    Leave {
134        id: String,
135    },
136    Closed {
137        id: String,
138    },
139    Ack {
140        id: String,
141        kind: AckKind,
142    },
143}
144
145#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
146#[serde(rename_all = "lowercase")]
147pub(crate) enum AckKind {
148    Leave,
149    Close,
150    Recovery,
151}
152
153#[derive(Default)]
154pub(crate) struct PeerRecovery {
155    pub local_ticket: Option<String>,
156    pub remote_ticket: Option<String>,
157    pub acknowledged: bool,
158    pub last_sent_ms: Option<u64>,
159    #[cfg(target_arch = "wasm32")]
160    pub mint_failure: Option<String>,
161}
162
163impl PeerRecovery {
164    fn complete(&self) -> bool {
165        self.local_ticket.is_some() && self.remote_ticket.is_some() && self.acknowledged
166    }
167}
168
169pub(crate) fn recovery_ready(ticket: &str, recovery: Option<&PeerRecovery>) -> bool {
170    let (endpoint, suffix) = split_ticket(ticket);
171    let negotiated = suffix
172        .and_then(|suffix| decode_payload(endpoint, suffix))
173        .is_some_and(|payload| payload.ticket_recovery == Some(1));
174    !negotiated || recovery.is_some_and(PeerRecovery::complete)
175}
176
177pub(crate) fn validate_recovery_ticket(id: &str, source: &str, ticket: &str) -> Result<()> {
178    if ticket.len() > MAX_INVITE_BYTES {
179        bail!("recovery credential exceeds size limit");
180    }
181    let (endpoint, suffix) = split_ticket(ticket);
182    let payload = suffix
183        .and_then(|suffix| decode_payload(endpoint, suffix))
184        .ok_or_else(|| anyhow::anyhow!("invalid recovery credential"))?;
185    if EndpointTicket::from_str(endpoint)?
186        .endpoint_addr()
187        .id
188        .to_string()
189        != source
190        || payload.scope.as_str() != format!("ticket:{id}")
191        || payload.token.trim().is_empty()
192        || payload.max_connections != 0
193        || payload.expires_at_ms.is_some()
194        || payload.ticket_hash.is_none()
195        || payload.audience.as_deref() != Some(TOKEN_PAYLOAD_ENDPOINT_TICKET_AUDIENCE)
196        || payload.nonce.is_none()
197    {
198        bail!("recovery credential changed its endpoint or scope");
199    }
200    Ok(())
201}
202
203pub(crate) async fn peer_recovery_ticket(
204    client: &Client,
205    id: &str,
206    peer: &crate::client::PeerSessionSnapshot,
207) -> Result<String> {
208    let scope = format!("ticket:{id}");
209    let connection = peer
210        .active_connection_id
211        .as_deref()
212        .ok_or_else(|| anyhow::anyhow!("missing recovery connection"))?;
213    let node = peer
214        .node_id
215        .as_deref()
216        .ok_or_else(|| anyhow::anyhow!("missing recovery endpoint"))?;
217    let endpoint = client.endpoint_ticket().await?;
218    let current = client
219        .peer_session(connection)
220        .await
221        .ok_or_else(|| anyhow::anyhow!("recovery peer retired"))?;
222    if !current.settled_ready
223        || current.logical_session_terminal
224        || current.active_connection_id.as_deref() != Some(connection)
225        || current.node_id.as_deref() != Some(node)
226        || current.active_transport_stable_id != peer.active_transport_stable_id
227        || current.transport_generation != peer.transport_generation
228        || !current.scopes.iter().any(|candidate| candidate == &scope)
229    {
230        bail!("recovery peer generation is no longer admitted");
231    }
232    let token = client
233        .session_token_registry
234        .register_ticket_peer_recovery(&scope, connection, node)
235        .map_err(anyhow::Error::msg)?;
236    Ok(crate::session_token::expiring_ticket(
237        &endpoint,
238        &token,
239        scope.as_str(),
240        0,
241        None,
242    ))
243}
244
245pub(crate) fn encode_wire(message: &Wire) -> Vec<u8> {
246    let mut frame = WIRE_PREFIX.to_vec();
247    frame.extend(serde_json::to_vec(message).expect("ticket mesh message serializes"));
248    frame
249}
250
251pub(crate) fn decode_wire(payload: &[u8]) -> Option<Wire> {
252    let body = payload.strip_prefix(WIRE_PREFIX)?;
253    if body.len() > 64 * 1024 {
254        return None;
255    }
256    serde_json::from_slice(body).ok()
257}
258
259/// Options for a small ticket-scoped peer mesh. Capacity includes the issuer.
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub struct TicketMeshOptions {
262    pub max_peers: u32,
263}
264
265impl Default for TicketMeshOptions {
266    fn default() -> Self {
267        Self {
268            max_peers: MAX_MESH_PEERS,
269        }
270    }
271}
272
273impl TicketMeshOptions {
274    pub fn validate(self) -> Result<Self> {
275        if !(2..=MAX_MESH_PEERS).contains(&self.max_peers) {
276            return Err(mesh_error(
277                TicketMeshErrorCode::InvalidOptions,
278                format!("ticket mesh max_peers must be between 2 and {MAX_MESH_PEERS}"),
279            ));
280        }
281        Ok(self)
282    }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(deny_unknown_fields)]
287struct InviteBody {
288    id: String,
289    ticket: String,
290    max_peers: u32,
291    mesh: bool,
292}
293
294/// A shareable bearer invitation. Treat its encoded form as a secret.
295#[derive(Clone)]
296pub struct TicketInvite(InviteBody, u64);
297
298/// One issuer-authorized guest endpoint. The bearer is transferred only over
299/// the ticket's protected issuer route and must never be logged.
300#[derive(Clone, serde::Serialize, serde::Deserialize)]
301#[serde(rename_all = "camelCase", deny_unknown_fields)]
302pub struct TicketMeshMember {
303    pub node_id: String,
304    pub ticket: String,
305}
306
307impl fmt::Debug for TicketMeshMember {
308    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
309        formatter
310            .debug_struct("TicketMeshMember")
311            .field("node_id", &self.node_id)
312            .field("ticket", &"[redacted]")
313            .finish()
314    }
315}
316
317/// Revision-fenced ticket membership, subordinate to the issuer's admitted
318/// route. This contains no device IDs or durable ownership state.
319pub struct TicketMeshRoster {
320    id: String,
321    issuer_node: String,
322    local_node: String,
323    max_peers: u32,
324    mesh: bool,
325    revision: u64,
326    members: BTreeMap<String, TicketMeshMember>,
327}
328
329impl TicketMeshRoster {
330    pub fn new(invite: &TicketInvite, local_node: &str) -> Result<Self> {
331        let issuer_node = invite.issuer_node()?;
332        let local_node = canonical_node(local_node)?;
333        if local_node == issuer_node {
334            bail!("ticket issuer cannot join its own invitation");
335        }
336        Ok(Self {
337            id: invite.id().to_string(),
338            issuer_node,
339            local_node,
340            max_peers: invite.max_peers(),
341            mesh: invite.is_mesh(),
342            revision: 0,
343            members: BTreeMap::new(),
344        })
345    }
346
347    /// `source_node` must come from the Rust-authenticated route used to
348    /// deliver the protected roster, never from the message body.
349    pub fn accept(
350        &mut self,
351        source_node: &str,
352        revision: u64,
353        members: Vec<TicketMeshMember>,
354    ) -> Result<bool> {
355        if canonical_node(source_node)? != self.issuer_node {
356            bail!("ticket roster did not come from the issuer");
357        }
358        if revision == 0 {
359            bail!("ticket roster revision must be positive");
360        }
361        if revision <= self.revision {
362            return Ok(false);
363        }
364        if members.len() >= self.max_peers as usize {
365            bail!("ticket roster exceeds participant limit");
366        }
367        let mut next = BTreeMap::new();
368        for mut member in members {
369            let node_id = canonical_node(&member.node_id)?;
370            if node_id == self.issuer_node {
371                bail!("issuer cannot appear in guest roster");
372            }
373            let grant = TicketInvite::new(
374                &self.id,
375                &member.ticket,
376                TicketMeshOptions {
377                    max_peers: self.max_peers,
378                },
379            )?;
380            if grant.issuer_node()? != node_id {
381                bail!("ticket roster member does not match its endpoint");
382            }
383            member.node_id = node_id.clone();
384            if next.insert(node_id, member).is_some() {
385                bail!("ticket roster contains a duplicate endpoint");
386            }
387        }
388        self.revision = revision;
389        self.members = next;
390        Ok(true)
391    }
392
393    pub fn revision(&self) -> u64 {
394        self.revision
395    }
396
397    pub fn contains_local(&self) -> bool {
398        self.members.contains_key(&self.local_node)
399    }
400
401    /// Membership alone does not acknowledge a refreshed guest bearer. Keep
402    /// publishing the current ticket until the authenticated issuer echoes it.
403    pub(crate) fn contains_local_ticket(&self, ticket: &str) -> bool {
404        self.members
405            .get(&self.local_node)
406            .is_some_and(|member| member.ticket == ticket)
407    }
408
409    pub fn desired(&self) -> Vec<TicketMeshMember> {
410        if !self.mesh || !self.contains_local() {
411            return Vec::new();
412        }
413        self.members
414            .iter()
415            .filter(|(node_id, _)| self.local_node.as_str() > node_id.as_str())
416            .map(|(_, member)| member.clone())
417            .collect()
418    }
419
420    pub fn allowed_nodes(&self) -> Vec<String> {
421        let mut nodes = vec![self.issuer_node.clone()];
422        if self.contains_local() {
423            nodes.extend(
424                self.members
425                    .keys()
426                    .filter(|id| *id != &self.local_node)
427                    .cloned(),
428            );
429        }
430        nodes
431    }
432}
433
434fn canonical_node(value: &str) -> Result<String> {
435    value
436        .trim()
437        .parse::<iroh::EndpointId>()
438        .map(|id| id.to_string())
439        .map_err(|_| anyhow::anyhow!("invalid ticket mesh endpoint id"))
440}
441
442impl TicketInvite {
443    /// Construct an invitation from a freshly minted, expiring compound
444    /// endpoint ticket. The grant scope must be `ticket:<id>`.
445    pub fn new(id: &str, ticket: &str, options: TicketMeshOptions) -> Result<Self> {
446        let options = options.validate()?;
447        let body = InviteBody {
448            id: id.to_owned(),
449            ticket: ticket.to_owned(),
450            max_peers: options.max_peers,
451            mesh: true,
452        };
453        let expiry = validate_body(&body).map_err(|error| {
454            mesh_error(TicketMeshErrorCode::InvalidInvitation, error.to_string())
455        })?;
456        Ok(Self(body, expiry))
457    }
458
459    /// A shareable invitation for a ticket that connects only to its issuer.
460    pub fn direct(id: &str, ticket: &str, max_peers: u32) -> Result<Self> {
461        TicketMeshOptions { max_peers }.validate()?;
462        let body = InviteBody {
463            id: id.to_owned(),
464            ticket: ticket.to_owned(),
465            max_peers,
466            mesh: false,
467        };
468        let expiry = validate_body(&body).map_err(|error| {
469            mesh_error(TicketMeshErrorCode::InvalidInvitation, error.to_string())
470        })?;
471        Ok(Self(body, expiry))
472    }
473
474    /// Parse and validate a transferable invitation before presenting its
475    /// endpoint grant to the Rust admission owner.
476    pub fn parse(value: &str) -> Result<Self> {
477        if value.len() > MAX_INVITE_BYTES || !value.starts_with(INVITE_PREFIX) {
478            return Err(mesh_error(
479                TicketMeshErrorCode::InvalidInvitation,
480                "invalid ticket invitation format",
481            ));
482        }
483        let encoded = &value[INVITE_PREFIX.len()..];
484        let decoded = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| {
485            mesh_error(
486                TicketMeshErrorCode::InvalidInvitation,
487                "invalid ticket invitation encoding",
488            )
489        })?;
490        if decoded.len() > MAX_INVITE_BYTES {
491            return Err(mesh_error(
492                TicketMeshErrorCode::InvalidInvitation,
493                "ticket invitation exceeds size limit",
494            ));
495        }
496        let body: InviteBody = serde_json::from_slice(&decoded).map_err(|_| {
497            mesh_error(
498                TicketMeshErrorCode::InvalidInvitation,
499                "invalid ticket invitation body",
500            )
501        })?;
502        let expiry = validate_body(&body).map_err(|error| {
503            mesh_error(TicketMeshErrorCode::InvalidInvitation, error.to_string())
504        })?;
505        Ok(Self(body, expiry))
506    }
507
508    pub fn id(&self) -> &str {
509        &self.0.id
510    }
511
512    pub fn endpoint_ticket(&self) -> &str {
513        &self.0.ticket
514    }
515
516    pub fn max_peers(&self) -> u32 {
517        self.0.max_peers
518    }
519
520    pub fn issuer_node(&self) -> Result<String> {
521        let (endpoint, _) = split_ticket(&self.0.ticket);
522        Ok(EndpointTicket::from_str(endpoint)?
523            .endpoint_addr()
524            .id
525            .to_string())
526    }
527
528    pub fn is_mesh(&self) -> bool {
529        self.0.mesh
530    }
531
532    /// Accept a newer bearer only for this same issuer and ticket avenue.
533    /// The protected roster transport authenticates who sent it; this check
534    /// prevents that transport from silently changing the invitation owner.
535    pub fn renewed(&self, candidate: &str) -> Result<Self> {
536        let next = Self::parse(candidate)?;
537        if next.id() != self.id()
538            || next.max_peers() != self.max_peers()
539            || next.is_mesh() != self.is_mesh()
540            || next.issuer_node()? != self.issuer_node()?
541            || next.expires_at_ms()? <= self.expires_at_ms()?
542        {
543            return Err(mesh_error(
544                TicketMeshErrorCode::InvalidInvitation,
545                "ticket invitation renewal changed its owner or did not extend expiry",
546            ));
547        }
548        Ok(next)
549    }
550
551    pub fn expires_at_ms(&self) -> Result<u64> {
552        // Immutable metadata captured by strict initial validation. An open
553        // mesh may compare a fresh, validated replacement with this historical
554        // deadline after suspension. This does not re-admit the old bearer;
555        // parsing/connecting still enforce current expiry in the admission owner.
556        Ok(self.1)
557    }
558
559    pub fn encode(&self) -> String {
560        let body = serde_json::to_vec(&self.0).expect("ticket invitation serializes");
561        format!("{INVITE_PREFIX}{}", URL_SAFE_NO_PAD.encode(body))
562    }
563}
564
565impl fmt::Debug for TicketInvite {
566    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
567        formatter
568            .debug_struct("TicketInvite")
569            .field("id", &self.0.id)
570            .field("max_peers", &self.0.max_peers)
571            .field("ticket", &"[redacted]")
572            .finish()
573    }
574}
575
576fn validate_body(body: &InviteBody) -> Result<u64> {
577    validate_id(&body.id)?;
578    TicketMeshOptions {
579        max_peers: body.max_peers,
580    }
581    .validate()?;
582    if body.ticket.is_empty() || body.ticket.len() > MAX_INVITE_BYTES {
583        bail!("invalid ticket invitation");
584    }
585    let (endpoint, suffix) = split_ticket(&body.ticket);
586    EndpointTicket::from_str(endpoint)
587        .map_err(|_| anyhow::anyhow!("ticket invitation has an invalid endpoint"))?;
588    let payload = suffix
589        .and_then(|suffix| decode_payload(endpoint, suffix))
590        .ok_or_else(|| anyhow::anyhow!("ticket invitation has no valid endpoint grant"))?;
591    if payload.scope.as_str() != format!("ticket:{}", body.id)
592        || payload.token.trim().is_empty()
593        || payload.max_connections != 0
594        || payload.ticket_hash.is_none()
595        || payload
596            .expires_at_ms
597            .is_none_or(|expiry| expiry <= crate::session_token::now_unix_ms())
598        || payload.audience.as_deref() != Some(TOKEN_PAYLOAD_ENDPOINT_TICKET_AUDIENCE)
599        || payload.nonce.is_none()
600    {
601        bail!("ticket invitation grant does not match its avenue");
602    }
603    Ok(payload.expires_at_ms.expect("validated invitation expiry"))
604}
605
606fn validate_id(id: &str) -> Result<()> {
607    if id.is_empty()
608        || id.len() > 160
609        || !id.bytes().all(|byte| {
610            byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b':' | b'@' | b'-')
611        })
612    {
613        bail!("invalid ticket invitation id");
614    }
615    Ok(())
616}
617
618impl Client {
619    /// Mint the issuer's transferable invitation on this existing Rust peer
620    /// runtime. Admission capacity belongs to the same Rust token registry;
621    /// reconnecting an authenticated guest does not consume another slot.
622    pub async fn issue_ticket_invite(
623        &self,
624        id: &str,
625        options: TicketMeshOptions,
626    ) -> Result<TicketInvite> {
627        self.issue_ticket_invite_with_mode(id, options, true).await
628    }
629
630    /// Mint an invitation for a direct issuer-to-guest ticket session.
631    pub async fn issue_direct_ticket_invite(
632        &self,
633        id: &str,
634        max_peers: u32,
635    ) -> Result<TicketInvite> {
636        self.issue_ticket_invite_with_mode(id, TicketMeshOptions { max_peers }, false)
637            .await
638    }
639
640    async fn issue_ticket_invite_with_mode(
641        &self,
642        id: &str,
643        options: TicketMeshOptions,
644        mesh: bool,
645    ) -> Result<TicketInvite> {
646        validate_id(id)?;
647        let options = options.validate()?;
648        let scope = format!("ticket:{id}");
649        self.session_token_registry
650            .set_ticket_peer_limit(&scope, options.max_peers)
651            .map_err(mesh_owner_error)?;
652        let ticket = match self.endpoint_ticket_with_token(&scope, 0).await {
653            Ok(ticket) => ticket,
654            Err(error) => {
655                self.session_token_registry
656                    .release_ticket_peer_limit(&scope);
657                return Err(error);
658            }
659        };
660        let invite = if mesh {
661            TicketInvite::new(id, &ticket, options)
662        } else {
663            TicketInvite::direct(id, &ticket, options.max_peers)
664        };
665        if invite.is_err() {
666            self.revoke_tokens_by_scope(&scope).await;
667        }
668        invite
669    }
670
671    /// Present the issuer's bearer once. The ticket-mesh session owner uses
672    /// this primitive for its initial route and subsequent recovery attempts.
673    pub async fn connect_ticket_invite(
674        &self,
675        invitation: &str,
676    ) -> Result<crate::client::ManagedConnectResult> {
677        let invite = TicketInvite::parse(invitation)?;
678        let (endpoint, _) = split_ticket(invite.endpoint_ticket());
679        let issuer = EndpointTicket::from_str(endpoint)?
680            .endpoint_addr()
681            .id
682            .to_string();
683        if self.current_node_id().await.as_deref() == Some(issuer.as_str()) {
684            return Err(mesh_error(
685                TicketMeshErrorCode::InvalidInvitation,
686                "cannot join a ticket issued by this endpoint",
687            ));
688        }
689        let connected = self
690            .connect_device(None, invite.endpoint_ticket())
691            .await
692            .map_err(|error| {
693                if error
694                    .to_string()
695                    .contains("ticket participant limit reached")
696                {
697                    mesh_error(TicketMeshErrorCode::CapacityExceeded, error.to_string())
698                } else {
699                    error
700                }
701            })?;
702        if connected.remote_node_id != issuer {
703            return Err(mesh_error(
704                TicketMeshErrorCode::InvalidInvitation,
705                "ticket route resolved to a different issuer endpoint",
706            ));
707        }
708        Ok(connected)
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use crate::session_token::{expiring_ticket, now_unix_ms};
716
717    fn ticket(id: &str) -> String {
718        let endpoint = EndpointTicket::new(iroh::EndpointAddr::new(
719            iroh::SecretKey::generate().public(),
720        ))
721        .to_string();
722        expiring_ticket(
723            &endpoint,
724            "secret",
725            format!("ticket:{id}"),
726            0,
727            Some(now_unix_ms() + 60_000),
728        )
729    }
730
731    fn ticket_for(id: &str, key: &iroh::SecretKey) -> String {
732        let endpoint = EndpointTicket::new(iroh::EndpointAddr::new(key.public())).to_string();
733        expiring_ticket(
734            &endpoint,
735            "secret",
736            format!("ticket:{id}"),
737            0,
738            Some(now_unix_ms() + 60_000),
739        )
740    }
741
742    #[test]
743    fn modern_ticket_readiness_requires_bilateral_recovery_and_ack() {
744        let endpoint = ticket("share-1");
745        let mut recovery = PeerRecovery::default();
746        assert!(!recovery_ready(&endpoint, None));
747        recovery.local_ticket = Some("local".into());
748        assert!(!recovery_ready(&endpoint, Some(&recovery)));
749        recovery.remote_ticket = Some("remote".into());
750        assert!(!recovery_ready(&endpoint, Some(&recovery)));
751        recovery.acknowledged = true;
752        assert!(recovery_ready(&endpoint, Some(&recovery)));
753        let (raw, suffix) = split_ticket(&endpoint);
754        let mut payload = decode_payload(raw, suffix.unwrap()).unwrap();
755        payload.ticket_recovery = None;
756        let legacy = format!(
757            "{}.{}",
758            raw,
759            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap())
760        );
761        assert!(recovery_ready(&legacy, None));
762    }
763
764    #[test]
765    fn recovery_credentials_are_private_to_endpoint_and_ticket() {
766        let key = iroh::SecretKey::generate();
767        let source = key.public().to_string();
768        let endpoint = EndpointTicket::new(iroh::EndpointAddr::new(key.public())).to_string();
769        let private = expiring_ticket(&endpoint, "private", "ticket:share-1", 0, None);
770        validate_recovery_ticket("share-1", &source, &private).unwrap();
771        assert!(TicketInvite::new("share-1", &private, TicketMeshOptions::default()).is_err());
772        assert!(validate_recovery_ticket("share-2", &source, &private).is_err());
773        assert!(validate_recovery_ticket(
774            "share-1",
775            &iroh::SecretKey::generate().public().to_string(),
776            &private
777        )
778        .is_err());
779        assert!(
780            validate_recovery_ticket("share-1", &source, &ticket_for("share-1", &key)).is_err()
781        );
782        assert!(validate_recovery_ticket(
783            "share-1",
784            &source,
785            &expiring_ticket(&endpoint, "private", "ticket:share-1", 1, None)
786        )
787        .is_err());
788        let (_, suffix) = split_ticket(&private);
789        let grafted = format!(
790            "{}.{}",
791            EndpointTicket::new(iroh::EndpointAddr::new(
792                iroh::SecretKey::generate().public()
793            )),
794            suffix.unwrap()
795        );
796        assert!(validate_recovery_ticket("share-1", &source, &grafted).is_err());
797        assert!(matches!(
798            decode_wire(&encode_wire(&Wire::Recovery {
799                id: "share-1".into(),
800                ticket: private
801            })),
802            Some(Wire::Recovery { .. })
803        ));
804        // Legacy readers reject an unknown control variant without changing
805        // Hello/Roster's established schema or admitting it as a new invite.
806        #[derive(Deserialize)]
807        #[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
808        enum LegacyWire {
809            Hello { id: String, ticket: String },
810        }
811        let hello = encode_wire(&Wire::Hello {
812            id: "share-1".into(),
813            ticket: "existing".into(),
814        });
815        let old: LegacyWire =
816            serde_json::from_slice(hello.strip_prefix(WIRE_PREFIX).unwrap()).unwrap();
817        let LegacyWire::Hello { id, ticket } = old;
818        assert_eq!((id.as_str(), ticket.as_str()), ("share-1", "existing"));
819        let recovery = encode_wire(&Wire::Recovery { id, ticket });
820        assert!(
821            serde_json::from_slice::<LegacyWire>(recovery.strip_prefix(WIRE_PREFIX).unwrap())
822                .is_err()
823        );
824    }
825
826    #[test]
827    fn round_trip_keeps_the_exact_bearer() {
828        let bearer = ticket("share-1");
829        let invite = TicketInvite::new("share-1", &bearer, TicketMeshOptions::default()).unwrap();
830        let parsed = TicketInvite::parse(&invite.encode()).unwrap();
831        assert_eq!(parsed.id(), "share-1");
832        assert_eq!(parsed.endpoint_ticket(), bearer);
833        assert_eq!(parsed.max_peers(), 8);
834        assert!(!format!("{parsed:?}").contains("secret"));
835    }
836
837    #[test]
838    fn rejects_wrong_scope_expiry_and_capacity() {
839        assert!(
840            TicketInvite::new("other", &ticket("share-1"), TicketMeshOptions::default()).is_err()
841        );
842        assert!(TicketInvite::new(
843            "share-1",
844            &ticket("share-1"),
845            TicketMeshOptions { max_peers: 9 }
846        )
847        .is_err());
848        let expired = expiring_ticket(
849            &EndpointTicket::new(iroh::EndpointAddr::new(
850                iroh::SecretKey::generate().public(),
851            ))
852            .to_string(),
853            "secret",
854            "ticket:share-1",
855            0,
856            Some(now_unix_ms().saturating_sub(1)),
857        );
858        assert!(TicketInvite::new("share-1", &expired, TicketMeshOptions::default()).is_err());
859    }
860
861    #[test]
862    fn exposes_stable_public_error_codes() {
863        let options_error = TicketMeshOptions { max_peers: 9 }.validate().unwrap_err();
864        assert_eq!(
865            options_error
866                .downcast_ref::<TicketMeshError>()
867                .map(TicketMeshError::code),
868            Some(TicketMeshErrorCode::InvalidOptions),
869        );
870        assert!(options_error
871            .to_string()
872            .starts_with("admission/ticket-mesh-invalid-options:"));
873
874        let invite_error = TicketInvite::parse("not-an-openrtc-invitation").unwrap_err();
875        assert_eq!(
876            invite_error
877                .downcast_ref::<TicketMeshError>()
878                .map(TicketMeshError::code),
879            Some(TicketMeshErrorCode::InvalidInvitation),
880        );
881        assert!(invite_error
882            .to_string()
883            .starts_with("admission/ticket-invite-invalid:"));
884    }
885
886    #[test]
887    fn rejects_unscoped_or_grafted_grants() {
888        assert!(
889            TicketInvite::new("share-1", "endpoint-ticket", TicketMeshOptions::default()).is_err()
890        );
891        let bearer = ticket("share-1");
892        let (_, suffix) = split_ticket(&bearer);
893        let grafted = format!("other-endpoint.{}", suffix.unwrap());
894        assert!(TicketInvite::new("share-1", &grafted, TicketMeshOptions::default()).is_err());
895    }
896
897    #[test]
898    fn direct_ticket_stays_direct() {
899        let invite = TicketInvite::direct("share-1", &ticket("share-1"), 2).unwrap();
900        assert!(!TicketInvite::parse(&invite.encode()).unwrap().is_mesh());
901    }
902
903    #[test]
904    fn renewal_keeps_issuer_and_extends_the_bearer_deadline() {
905        let issuer = iroh::SecretKey::generate();
906        let other = iroh::SecretKey::generate();
907        let endpoint = EndpointTicket::new(iroh::EndpointAddr::new(issuer.public())).to_string();
908        let original = TicketInvite::new(
909            "share-1",
910            &expiring_ticket(
911                &endpoint,
912                "first",
913                "ticket:share-1",
914                0,
915                Some(now_unix_ms() + 60_000),
916            ),
917            TicketMeshOptions { max_peers: 3 },
918        )
919        .unwrap();
920        let next = TicketInvite::new(
921            "share-1",
922            &expiring_ticket(
923                &endpoint,
924                "second",
925                "ticket:share-1",
926                0,
927                Some(now_unix_ms() + 120_000),
928            ),
929            TicketMeshOptions { max_peers: 3 },
930        )
931        .unwrap();
932        assert_eq!(
933            original.renewed(&next.encode()).unwrap().endpoint_ticket(),
934            next.endpoint_ticket()
935        );
936        assert!(next.renewed(&original.encode()).is_err());
937        let different_issuer = TicketInvite::new(
938            "share-1",
939            &ticket_for("share-1", &other),
940            TicketMeshOptions { max_peers: 3 },
941        )
942        .unwrap();
943        assert!(original.renewed(&different_issuer.encode()).is_err());
944        let different_limit = TicketInvite::new(
945            "share-1",
946            next.endpoint_ticket(),
947            TicketMeshOptions { max_peers: 4 },
948        )
949        .unwrap();
950        assert!(original.renewed(&different_limit.encode()).is_err());
951    }
952
953    #[test]
954    #[cfg(not(target_arch = "wasm32"))]
955    fn retained_invitation_can_renew_after_its_original_bearer_expires() {
956        let issuer = iroh::SecretKey::generate();
957        let endpoint = EndpointTicket::new(iroh::EndpointAddr::new(issuer.public())).to_string();
958        let expiry = now_unix_ms() + 2_000;
959        let original = TicketInvite::new(
960            "share-1",
961            &expiring_ticket(&endpoint, "first", "ticket:share-1", 0, Some(expiry)),
962            TicketMeshOptions { max_peers: 3 },
963        )
964        .unwrap();
965        // The owner retains a previously validated invitation across a real
966        // expiry. Do not alter clocks or admit an expired invitation afresh.
967        while now_unix_ms() <= expiry {
968            std::thread::sleep(Duration::from_millis(10));
969        }
970        assert!(TicketInvite::parse(&original.encode()).is_err());
971        let next = TicketInvite::new(
972            "share-1",
973            &expiring_ticket(
974                &endpoint,
975                "second",
976                "ticket:share-1",
977                0,
978                Some(now_unix_ms() + 60_000),
979            ),
980            TicketMeshOptions { max_peers: 3 },
981        )
982        .unwrap();
983        assert_eq!(original.expires_at_ms().unwrap(), expiry);
984        assert_eq!(
985            original.renewed(&next.encode()).unwrap().endpoint_ticket(),
986            next.endpoint_ticket()
987        );
988        assert!(next.renewed(&original.encode()).is_err());
989    }
990
991    #[test]
992    fn renewed_guest_ticket_requires_exact_issuer_acknowledgement() {
993        let host = iroh::SecretKey::generate();
994        let guest = iroh::SecretKey::generate();
995        let invite = TicketInvite::new(
996            "share-1",
997            &ticket_for("share-1", &host),
998            TicketMeshOptions { max_peers: 3 },
999        )
1000        .unwrap();
1001        let old_ticket = ticket_for("share-1", &guest);
1002        let endpoint = EndpointTicket::new(iroh::EndpointAddr::new(guest.public())).to_string();
1003        let new_ticket = expiring_ticket(
1004            &endpoint,
1005            "fresh-secret",
1006            "ticket:share-1",
1007            0,
1008            Some(now_unix_ms() + 120_000),
1009        );
1010        let member = |ticket: String| TicketMeshMember {
1011            node_id: guest.public().to_string(),
1012            ticket,
1013        };
1014        let mut roster = TicketMeshRoster::new(&invite, &guest.public().to_string()).unwrap();
1015        roster
1016            .accept(
1017                &host.public().to_string(),
1018                1,
1019                vec![member(old_ticket.clone())],
1020            )
1021            .unwrap();
1022        assert!(roster.contains_local());
1023        assert!(roster.contains_local_ticket(&old_ticket));
1024        assert!(
1025            !roster.contains_local_ticket(&new_ticket),
1026            "membership must not suppress renewed Hello"
1027        );
1028        assert!(!roster
1029            .accept(
1030                &host.public().to_string(),
1031                1,
1032                vec![member(new_ticket.clone())]
1033            )
1034            .unwrap());
1035        assert!(
1036            !roster.contains_local_ticket(&new_ticket),
1037            "stale roster cannot acknowledge renewal"
1038        );
1039        roster
1040            .accept(
1041                &host.public().to_string(),
1042                2,
1043                vec![member(new_ticket.clone())],
1044            )
1045            .unwrap();
1046        assert!(roster.contains_local_ticket(&new_ticket));
1047        assert!(!roster.contains_local_ticket(&old_ticket));
1048    }
1049
1050    #[test]
1051    fn issuer_roster_is_revision_fenced_and_selects_one_guest_dialer() {
1052        let host = iroh::SecretKey::generate();
1053        let guest_a = iroh::SecretKey::generate();
1054        let guest_b = iroh::SecretKey::generate();
1055        let invite = TicketInvite::new(
1056            "share-1",
1057            &ticket_for("share-1", &host),
1058            TicketMeshOptions { max_peers: 3 },
1059        )
1060        .unwrap();
1061        let (local, other) = if guest_a.public().to_string() > guest_b.public().to_string() {
1062            (&guest_a, &guest_b)
1063        } else {
1064            (&guest_b, &guest_a)
1065        };
1066        let members = vec![
1067            TicketMeshMember {
1068                node_id: local.public().to_string(),
1069                ticket: ticket_for("share-1", local),
1070            },
1071            TicketMeshMember {
1072                node_id: other.public().to_string(),
1073                ticket: ticket_for("share-1", other),
1074            },
1075        ];
1076        let mut roster = TicketMeshRoster::new(&invite, &local.public().to_string()).unwrap();
1077        assert!(roster
1078            .accept(&host.public().to_string(), 1, members.clone())
1079            .unwrap());
1080        assert!(roster.contains_local());
1081        assert_eq!(roster.desired().len(), 1);
1082        assert_eq!(roster.desired()[0].node_id, other.public().to_string());
1083        assert!(!roster
1084            .accept(&host.public().to_string(), 1, Vec::new())
1085            .unwrap());
1086        assert_eq!(roster.revision(), 1);
1087        assert_eq!(roster.allowed_nodes().len(), 2);
1088        assert!(roster
1089            .accept(&host.public().to_string(), 2, Vec::new())
1090            .unwrap());
1091        assert!(!roster.contains_local());
1092        assert!(roster.desired().is_empty());
1093        assert_eq!(roster.allowed_nodes(), vec![host.public().to_string()]);
1094    }
1095
1096    #[test]
1097    fn roster_rejects_forged_source_and_ticket_endpoint() {
1098        let host = iroh::SecretKey::generate();
1099        let guest = iroh::SecretKey::generate();
1100        let other = iroh::SecretKey::generate();
1101        let invite = TicketInvite::new(
1102            "share-1",
1103            &ticket_for("share-1", &host),
1104            TicketMeshOptions { max_peers: 3 },
1105        )
1106        .unwrap();
1107        let member = TicketMeshMember {
1108            node_id: guest.public().to_string(),
1109            ticket: ticket_for("share-1", &guest),
1110        };
1111        let mut roster = TicketMeshRoster::new(&invite, &guest.public().to_string()).unwrap();
1112        assert!(roster
1113            .accept(&other.public().to_string(), 1, vec![member.clone()])
1114            .is_err());
1115        assert!(roster
1116            .accept(
1117                &host.public().to_string(),
1118                1,
1119                vec![TicketMeshMember {
1120                    node_id: guest.public().to_string(),
1121                    ticket: ticket_for("share-1", &other)
1122                },]
1123            )
1124            .is_err());
1125        assert!(roster
1126            .accept(&host.public().to_string(), 1, vec![member.clone(), member])
1127            .is_err());
1128        assert_eq!(roster.revision(), 0);
1129    }
1130}