use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
use std::time::Duration;
use anyhow::{bail, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use iroh_tickets::endpoint::EndpointTicket;
use serde::{Deserialize, Serialize};
use crate::session_token::{decode_payload, split_ticket, TOKEN_PAYLOAD_ENDPOINT_TICKET_AUDIENCE};
use crate::Client;
#[cfg(not(target_arch = "wasm32"))]
mod session;
#[cfg(not(target_arch = "wasm32"))]
pub use session::TicketMesh;
#[cfg(target_arch = "wasm32")]
pub(crate) mod wasm_session;
const INVITE_PREFIX: &str = "openrtc-ticket-v1.";
const MAX_INVITE_BYTES: usize = 16 * 1024;
const MAX_MESH_PEERS: u32 = 8;
pub(crate) const WIRE_PREFIX: &[u8] = b"openrtc.ticket.mesh.v1\0";
pub(crate) const ROSTER_RESEND: Duration = Duration::from_secs(15);
pub(crate) const HELLO_RETRY: Duration = Duration::from_secs(3);
pub(crate) const GUEST_GRACE: Duration = Duration::from_secs(45);
pub(crate) const MAX_RETRY: Duration = Duration::from_secs(10);
pub(crate) const ADMISSION_LEASE: Duration = Duration::from_secs(90);
pub(crate) const ADMISSION_REFRESH: Duration = Duration::from_secs(30);
pub(crate) const INVITE_REFRESH: Duration = Duration::from_secs(10 * 60);
pub(crate) const INVITE_REFRESH_RETRY: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TicketMeshErrorCode {
InvalidInvitation,
InvalidOptions,
CapacityExceeded,
AlreadyActive,
Unavailable,
}
impl TicketMeshErrorCode {
pub const fn as_str(self) -> &'static str {
match self {
Self::InvalidInvitation => "admission/ticket-invite-invalid",
Self::InvalidOptions => "admission/ticket-mesh-invalid-options",
Self::CapacityExceeded => "admission/ticket-mesh-capacity-exceeded",
Self::AlreadyActive => "admission/ticket-mesh-already-active",
Self::Unavailable => "coordination/ticket-mesh-unavailable",
}
}
}
impl fmt::Display for TicketMeshErrorCode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug)]
pub struct TicketMeshError {
code: TicketMeshErrorCode,
message: String,
}
impl TicketMeshError {
pub fn new(code: TicketMeshErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
pub const fn code(&self) -> TicketMeshErrorCode {
self.code
}
}
impl fmt::Display for TicketMeshError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for TicketMeshError {}
fn mesh_error(code: TicketMeshErrorCode, message: impl Into<String>) -> anyhow::Error {
TicketMeshError::new(code, message).into()
}
fn mesh_owner_error(message: String) -> anyhow::Error {
let code = if message.contains("already has an issuer owner") {
TicketMeshErrorCode::AlreadyActive
} else if message.contains("invalid ticket participant limit") {
TicketMeshErrorCode::InvalidOptions
} else {
TicketMeshErrorCode::Unavailable
};
mesh_error(code, message)
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
pub(crate) enum Wire {
Hello {
id: String,
ticket: String,
},
Roster {
id: String,
revision: u64,
members: Vec<TicketMeshMember>,
issuer_invite: String,
},
Leave {
id: String,
},
Closed {
id: String,
},
Ack {
id: String,
kind: AckKind,
},
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum AckKind {
Leave,
Close,
}
pub(crate) fn encode_wire(message: &Wire) -> Vec<u8> {
let mut frame = WIRE_PREFIX.to_vec();
frame.extend(serde_json::to_vec(message).expect("ticket mesh message serializes"));
frame
}
pub(crate) fn decode_wire(payload: &[u8]) -> Option<Wire> {
let body = payload.strip_prefix(WIRE_PREFIX)?;
if body.len() > 64 * 1024 {
return None;
}
serde_json::from_slice(body).ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TicketMeshOptions {
pub max_peers: u32,
}
impl Default for TicketMeshOptions {
fn default() -> Self {
Self {
max_peers: MAX_MESH_PEERS,
}
}
}
impl TicketMeshOptions {
pub fn validate(self) -> Result<Self> {
if !(2..=MAX_MESH_PEERS).contains(&self.max_peers) {
return Err(mesh_error(
TicketMeshErrorCode::InvalidOptions,
format!("ticket mesh max_peers must be between 2 and {MAX_MESH_PEERS}"),
));
}
Ok(self)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct InviteBody {
id: String,
ticket: String,
max_peers: u32,
mesh: bool,
}
#[derive(Clone)]
pub struct TicketInvite(InviteBody);
#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TicketMeshMember {
pub node_id: String,
pub ticket: String,
}
impl fmt::Debug for TicketMeshMember {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TicketMeshMember")
.field("node_id", &self.node_id)
.field("ticket", &"[redacted]")
.finish()
}
}
pub struct TicketMeshRoster {
id: String,
issuer_node: String,
local_node: String,
max_peers: u32,
mesh: bool,
revision: u64,
members: BTreeMap<String, TicketMeshMember>,
}
impl TicketMeshRoster {
pub fn new(invite: &TicketInvite, local_node: &str) -> Result<Self> {
let issuer_node = invite.issuer_node()?;
let local_node = canonical_node(local_node)?;
if local_node == issuer_node {
bail!("ticket issuer cannot join its own invitation");
}
Ok(Self {
id: invite.id().to_string(),
issuer_node,
local_node,
max_peers: invite.max_peers(),
mesh: invite.is_mesh(),
revision: 0,
members: BTreeMap::new(),
})
}
pub fn accept(
&mut self,
source_node: &str,
revision: u64,
members: Vec<TicketMeshMember>,
) -> Result<bool> {
if canonical_node(source_node)? != self.issuer_node {
bail!("ticket roster did not come from the issuer");
}
if revision == 0 {
bail!("ticket roster revision must be positive");
}
if revision <= self.revision {
return Ok(false);
}
if members.len() >= self.max_peers as usize {
bail!("ticket roster exceeds participant limit");
}
let mut next = BTreeMap::new();
for mut member in members {
let node_id = canonical_node(&member.node_id)?;
if node_id == self.issuer_node {
bail!("issuer cannot appear in guest roster");
}
let grant = TicketInvite::new(
&self.id,
&member.ticket,
TicketMeshOptions {
max_peers: self.max_peers,
},
)?;
if grant.issuer_node()? != node_id {
bail!("ticket roster member does not match its endpoint");
}
member.node_id = node_id.clone();
if next.insert(node_id, member).is_some() {
bail!("ticket roster contains a duplicate endpoint");
}
}
self.revision = revision;
self.members = next;
Ok(true)
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn contains_local(&self) -> bool {
self.members.contains_key(&self.local_node)
}
pub fn desired(&self) -> Vec<TicketMeshMember> {
if !self.mesh || !self.contains_local() {
return Vec::new();
}
self.members
.iter()
.filter(|(node_id, _)| self.local_node.as_str() > node_id.as_str())
.map(|(_, member)| member.clone())
.collect()
}
pub fn allowed_nodes(&self) -> Vec<String> {
let mut nodes = vec![self.issuer_node.clone()];
if self.contains_local() {
nodes.extend(
self.members
.keys()
.filter(|id| *id != &self.local_node)
.cloned(),
);
}
nodes
}
}
fn canonical_node(value: &str) -> Result<String> {
value
.trim()
.parse::<iroh::EndpointId>()
.map(|id| id.to_string())
.map_err(|_| anyhow::anyhow!("invalid ticket mesh endpoint id"))
}
impl TicketInvite {
pub fn new(id: &str, ticket: &str, options: TicketMeshOptions) -> Result<Self> {
let options = options.validate()?;
let body = InviteBody {
id: id.to_owned(),
ticket: ticket.to_owned(),
max_peers: options.max_peers,
mesh: true,
};
validate_body(&body).map_err(|error| {
mesh_error(TicketMeshErrorCode::InvalidInvitation, error.to_string())
})?;
Ok(Self(body))
}
pub fn direct(id: &str, ticket: &str, max_peers: u32) -> Result<Self> {
TicketMeshOptions { max_peers }.validate()?;
let body = InviteBody {
id: id.to_owned(),
ticket: ticket.to_owned(),
max_peers,
mesh: false,
};
validate_body(&body).map_err(|error| {
mesh_error(TicketMeshErrorCode::InvalidInvitation, error.to_string())
})?;
Ok(Self(body))
}
pub fn parse(value: &str) -> Result<Self> {
if value.len() > MAX_INVITE_BYTES || !value.starts_with(INVITE_PREFIX) {
return Err(mesh_error(
TicketMeshErrorCode::InvalidInvitation,
"invalid ticket invitation format",
));
}
let encoded = &value[INVITE_PREFIX.len()..];
let decoded = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| {
mesh_error(
TicketMeshErrorCode::InvalidInvitation,
"invalid ticket invitation encoding",
)
})?;
if decoded.len() > MAX_INVITE_BYTES {
return Err(mesh_error(
TicketMeshErrorCode::InvalidInvitation,
"ticket invitation exceeds size limit",
));
}
let body: InviteBody = serde_json::from_slice(&decoded).map_err(|_| {
mesh_error(
TicketMeshErrorCode::InvalidInvitation,
"invalid ticket invitation body",
)
})?;
validate_body(&body).map_err(|error| {
mesh_error(TicketMeshErrorCode::InvalidInvitation, error.to_string())
})?;
Ok(Self(body))
}
pub fn id(&self) -> &str {
&self.0.id
}
pub fn endpoint_ticket(&self) -> &str {
&self.0.ticket
}
pub fn max_peers(&self) -> u32 {
self.0.max_peers
}
pub fn issuer_node(&self) -> Result<String> {
let (endpoint, _) = split_ticket(&self.0.ticket);
Ok(EndpointTicket::from_str(endpoint)?
.endpoint_addr()
.id
.to_string())
}
pub fn is_mesh(&self) -> bool {
self.0.mesh
}
pub fn renewed(&self, candidate: &str) -> Result<Self> {
let next = Self::parse(candidate)?;
if next.id() != self.id()
|| next.max_peers() != self.max_peers()
|| next.is_mesh() != self.is_mesh()
|| next.issuer_node()? != self.issuer_node()?
|| next.expires_at_ms()? <= self.expires_at_ms()?
{
return Err(mesh_error(
TicketMeshErrorCode::InvalidInvitation,
"ticket invitation renewal changed its owner or did not extend expiry",
));
}
Ok(next)
}
pub fn expires_at_ms(&self) -> Result<u64> {
let (endpoint, suffix) = split_ticket(&self.0.ticket);
let payload = suffix
.and_then(|suffix| decode_payload(endpoint, suffix))
.ok_or_else(|| {
mesh_error(
TicketMeshErrorCode::InvalidInvitation,
"ticket invitation has no valid endpoint grant",
)
})?;
payload.expires_at_ms.ok_or_else(|| {
mesh_error(
TicketMeshErrorCode::InvalidInvitation,
"ticket invitation has no expiry",
)
})
}
pub fn encode(&self) -> String {
let body = serde_json::to_vec(&self.0).expect("ticket invitation serializes");
format!("{INVITE_PREFIX}{}", URL_SAFE_NO_PAD.encode(body))
}
}
impl fmt::Debug for TicketInvite {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TicketInvite")
.field("id", &self.0.id)
.field("max_peers", &self.0.max_peers)
.field("ticket", &"[redacted]")
.finish()
}
}
fn validate_body(body: &InviteBody) -> Result<()> {
validate_id(&body.id)?;
TicketMeshOptions {
max_peers: body.max_peers,
}
.validate()?;
if body.ticket.is_empty() || body.ticket.len() > MAX_INVITE_BYTES {
bail!("invalid ticket invitation");
}
let (endpoint, suffix) = split_ticket(&body.ticket);
EndpointTicket::from_str(endpoint)
.map_err(|_| anyhow::anyhow!("ticket invitation has an invalid endpoint"))?;
let payload = suffix
.and_then(|suffix| decode_payload(endpoint, suffix))
.ok_or_else(|| anyhow::anyhow!("ticket invitation has no valid endpoint grant"))?;
if payload.scope.as_str() != format!("ticket:{}", body.id)
|| payload.token.trim().is_empty()
|| payload.max_connections != 0
|| payload.ticket_hash.is_none()
|| payload
.expires_at_ms
.is_none_or(|expiry| expiry <= crate::session_token::now_unix_ms())
|| payload.audience.as_deref() != Some(TOKEN_PAYLOAD_ENDPOINT_TICKET_AUDIENCE)
|| payload.nonce.is_none()
{
bail!("ticket invitation grant does not match its avenue");
}
Ok(())
}
fn validate_id(id: &str) -> Result<()> {
if id.is_empty()
|| id.len() > 160
|| !id.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b':' | b'@' | b'-')
})
{
bail!("invalid ticket invitation id");
}
Ok(())
}
impl Client {
pub async fn issue_ticket_invite(
&self,
id: &str,
options: TicketMeshOptions,
) -> Result<TicketInvite> {
self.issue_ticket_invite_with_mode(id, options, true).await
}
pub async fn issue_direct_ticket_invite(
&self,
id: &str,
max_peers: u32,
) -> Result<TicketInvite> {
self.issue_ticket_invite_with_mode(id, TicketMeshOptions { max_peers }, false)
.await
}
async fn issue_ticket_invite_with_mode(
&self,
id: &str,
options: TicketMeshOptions,
mesh: bool,
) -> Result<TicketInvite> {
validate_id(id)?;
let options = options.validate()?;
let scope = format!("ticket:{id}");
self.session_token_registry
.set_ticket_peer_limit(&scope, options.max_peers)
.map_err(mesh_owner_error)?;
let ticket = match self.endpoint_ticket_with_token(&scope, 0).await {
Ok(ticket) => ticket,
Err(error) => {
self.session_token_registry
.release_ticket_peer_limit(&scope);
return Err(error);
}
};
let invite = if mesh {
TicketInvite::new(id, &ticket, options)
} else {
TicketInvite::direct(id, &ticket, options.max_peers)
};
if invite.is_err() {
self.revoke_tokens_by_scope(&scope).await;
}
invite
}
pub async fn connect_ticket_invite(
&self,
invitation: &str,
) -> Result<crate::client::ManagedConnectResult> {
let invite = TicketInvite::parse(invitation)?;
let (endpoint, _) = split_ticket(invite.endpoint_ticket());
let issuer = EndpointTicket::from_str(endpoint)?
.endpoint_addr()
.id
.to_string();
if self.current_node_id().await.as_deref() == Some(issuer.as_str()) {
return Err(mesh_error(
TicketMeshErrorCode::InvalidInvitation,
"cannot join a ticket issued by this endpoint",
));
}
let connected = self
.connect_device(None, invite.endpoint_ticket())
.await
.map_err(|error| {
if error
.to_string()
.contains("ticket participant limit reached")
{
mesh_error(TicketMeshErrorCode::CapacityExceeded, error.to_string())
} else {
error
}
})?;
if connected.remote_node_id != issuer {
return Err(mesh_error(
TicketMeshErrorCode::InvalidInvitation,
"ticket route resolved to a different issuer endpoint",
));
}
Ok(connected)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session_token::{expiring_ticket, now_unix_ms};
fn ticket(id: &str) -> String {
let endpoint = EndpointTicket::new(iroh::EndpointAddr::new(
iroh::SecretKey::generate().public(),
))
.to_string();
expiring_ticket(
&endpoint,
"secret",
format!("ticket:{id}"),
0,
Some(now_unix_ms() + 60_000),
)
}
fn ticket_for(id: &str, key: &iroh::SecretKey) -> String {
let endpoint = EndpointTicket::new(iroh::EndpointAddr::new(key.public())).to_string();
expiring_ticket(
&endpoint,
"secret",
format!("ticket:{id}"),
0,
Some(now_unix_ms() + 60_000),
)
}
#[test]
fn round_trip_keeps_the_exact_bearer() {
let bearer = ticket("share-1");
let invite = TicketInvite::new("share-1", &bearer, TicketMeshOptions::default()).unwrap();
let parsed = TicketInvite::parse(&invite.encode()).unwrap();
assert_eq!(parsed.id(), "share-1");
assert_eq!(parsed.endpoint_ticket(), bearer);
assert_eq!(parsed.max_peers(), 8);
assert!(!format!("{parsed:?}").contains("secret"));
}
#[test]
fn rejects_wrong_scope_expiry_and_capacity() {
assert!(
TicketInvite::new("other", &ticket("share-1"), TicketMeshOptions::default()).is_err()
);
assert!(TicketInvite::new(
"share-1",
&ticket("share-1"),
TicketMeshOptions { max_peers: 9 }
)
.is_err());
let expired = expiring_ticket(
&EndpointTicket::new(iroh::EndpointAddr::new(
iroh::SecretKey::generate().public(),
))
.to_string(),
"secret",
"ticket:share-1",
0,
Some(now_unix_ms().saturating_sub(1)),
);
assert!(TicketInvite::new("share-1", &expired, TicketMeshOptions::default()).is_err());
}
#[test]
fn exposes_stable_public_error_codes() {
let options_error = TicketMeshOptions { max_peers: 9 }.validate().unwrap_err();
assert_eq!(
options_error
.downcast_ref::<TicketMeshError>()
.map(TicketMeshError::code),
Some(TicketMeshErrorCode::InvalidOptions),
);
assert!(options_error
.to_string()
.starts_with("admission/ticket-mesh-invalid-options:"));
let invite_error = TicketInvite::parse("not-an-openrtc-invitation").unwrap_err();
assert_eq!(
invite_error
.downcast_ref::<TicketMeshError>()
.map(TicketMeshError::code),
Some(TicketMeshErrorCode::InvalidInvitation),
);
assert!(invite_error
.to_string()
.starts_with("admission/ticket-invite-invalid:"));
}
#[test]
fn rejects_unscoped_or_grafted_grants() {
assert!(
TicketInvite::new("share-1", "endpoint-ticket", TicketMeshOptions::default()).is_err()
);
let bearer = ticket("share-1");
let (_, suffix) = split_ticket(&bearer);
let grafted = format!("other-endpoint.{}", suffix.unwrap());
assert!(TicketInvite::new("share-1", &grafted, TicketMeshOptions::default()).is_err());
}
#[test]
fn direct_ticket_stays_direct() {
let invite = TicketInvite::direct("share-1", &ticket("share-1"), 2).unwrap();
assert!(!TicketInvite::parse(&invite.encode()).unwrap().is_mesh());
}
#[test]
fn renewal_keeps_issuer_and_extends_the_bearer_deadline() {
let issuer = iroh::SecretKey::generate();
let other = iroh::SecretKey::generate();
let endpoint = EndpointTicket::new(iroh::EndpointAddr::new(issuer.public())).to_string();
let original = TicketInvite::new(
"share-1",
&expiring_ticket(
&endpoint,
"first",
"ticket:share-1",
0,
Some(now_unix_ms() + 60_000),
),
TicketMeshOptions { max_peers: 3 },
)
.unwrap();
let next = TicketInvite::new(
"share-1",
&expiring_ticket(
&endpoint,
"second",
"ticket:share-1",
0,
Some(now_unix_ms() + 120_000),
),
TicketMeshOptions { max_peers: 3 },
)
.unwrap();
assert_eq!(
original.renewed(&next.encode()).unwrap().endpoint_ticket(),
next.endpoint_ticket()
);
assert!(next.renewed(&original.encode()).is_err());
let different_issuer = TicketInvite::new(
"share-1",
&ticket_for("share-1", &other),
TicketMeshOptions { max_peers: 3 },
)
.unwrap();
assert!(original.renewed(&different_issuer.encode()).is_err());
let different_limit = TicketInvite::new(
"share-1",
next.endpoint_ticket(),
TicketMeshOptions { max_peers: 4 },
)
.unwrap();
assert!(original.renewed(&different_limit.encode()).is_err());
}
#[test]
fn issuer_roster_is_revision_fenced_and_selects_one_guest_dialer() {
let host = iroh::SecretKey::generate();
let guest_a = iroh::SecretKey::generate();
let guest_b = iroh::SecretKey::generate();
let invite = TicketInvite::new(
"share-1",
&ticket_for("share-1", &host),
TicketMeshOptions { max_peers: 3 },
)
.unwrap();
let (local, other) = if guest_a.public().to_string() > guest_b.public().to_string() {
(&guest_a, &guest_b)
} else {
(&guest_b, &guest_a)
};
let members = vec![
TicketMeshMember {
node_id: local.public().to_string(),
ticket: ticket_for("share-1", local),
},
TicketMeshMember {
node_id: other.public().to_string(),
ticket: ticket_for("share-1", other),
},
];
let mut roster = TicketMeshRoster::new(&invite, &local.public().to_string()).unwrap();
assert!(roster
.accept(&host.public().to_string(), 1, members.clone())
.unwrap());
assert!(roster.contains_local());
assert_eq!(roster.desired().len(), 1);
assert_eq!(roster.desired()[0].node_id, other.public().to_string());
assert!(!roster
.accept(&host.public().to_string(), 1, Vec::new())
.unwrap());
assert_eq!(roster.revision(), 1);
assert_eq!(roster.allowed_nodes().len(), 2);
assert!(roster
.accept(&host.public().to_string(), 2, Vec::new())
.unwrap());
assert!(!roster.contains_local());
assert!(roster.desired().is_empty());
assert_eq!(roster.allowed_nodes(), vec![host.public().to_string()]);
}
#[test]
fn roster_rejects_forged_source_and_ticket_endpoint() {
let host = iroh::SecretKey::generate();
let guest = iroh::SecretKey::generate();
let other = iroh::SecretKey::generate();
let invite = TicketInvite::new(
"share-1",
&ticket_for("share-1", &host),
TicketMeshOptions { max_peers: 3 },
)
.unwrap();
let member = TicketMeshMember {
node_id: guest.public().to_string(),
ticket: ticket_for("share-1", &guest),
};
let mut roster = TicketMeshRoster::new(&invite, &guest.public().to_string()).unwrap();
assert!(roster
.accept(&other.public().to_string(), 1, vec![member.clone()])
.is_err());
assert!(roster
.accept(
&host.public().to_string(),
1,
vec![TicketMeshMember {
node_id: guest.public().to_string(),
ticket: ticket_for("share-1", &other)
},]
)
.is_err());
assert!(roster
.accept(&host.public().to_string(), 1, vec![member.clone(), member])
.is_err());
assert_eq!(roster.revision(), 0);
}
}