1use 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
32pub(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#[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#[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#[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#[derive(Clone)]
296pub struct TicketInvite(InviteBody, u64);
297
298#[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
317pub 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 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 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 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 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 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 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 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 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 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 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 #[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 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}