use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use anyhow::{Context, bail};
use tokio::io::BufReader;
use mcpmesh_local_api::PairResult;
use mcpmesh_net::framing::{FrameReader, Inbound, write_frame};
use crate::allowlist::{PeerEntry, PeerStore};
use crate::pairing::sas::short_auth_code;
use crate::pairing::{Invite, LiveInvites, Redeem};
use crate::util::epoch_now_u64 as epoch_now;
const MAX_PAIR_FRAME: usize = 64 * 1024;
const REASON_REFUSED: &str = "pairing refused";
const REASON_MALFORMED: &str = "malformed request";
const REASON_ID_MISMATCH: &str = "id mismatch";
#[derive(serde::Serialize, serde::Deserialize)]
struct RedeemerHello {
secret: [u8; 32],
redeemer_id: [u8; 32],
redeemer_nickname: String,
#[serde(default)]
user_pk: Option<String>,
#[serde(default)]
binding_sig: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(tag = "result", rename_all = "snake_case")]
enum PairReply {
Ok {
inviter_id: [u8; 32],
inviter_nickname: String,
#[serde(default)]
user_pk: Option<String>,
#[serde(default)]
binding_sig: Option<String>,
},
Refused {
reason: String,
},
}
#[derive(Clone, Debug)]
pub struct SelfBinding {
pub user_pk: String,
pub sig: String,
}
pub type GrantFn = Box<
dyn Fn(String, String, Vec<String>) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>>
+ Send
+ Sync,
>;
pub type RecordPairingFn = Box<dyn Fn(String, String, u64) + Send + Sync>;
pub struct InviterCtx {
pub store: Arc<PeerStore>,
pub invites: Arc<LiveInvites>,
pub config_path: PathBuf,
pub self_binding: Option<SelfBinding>,
pub grant: GrantFn,
pub record_pairing: RecordPairingFn,
}
fn verified_user_id(
user_pk: &Option<String>,
binding_sig: &Option<String>,
authenticated_id: &[u8; 32],
) -> Option<String> {
match (user_pk, binding_sig) {
(Some(pk), Some(sig)) => {
match mcpmesh_trust::binding::verify_presented(pk, sig, authenticated_id) {
Ok(uid) => Some(uid),
Err(e) => {
tracing::warn!(
%e,
"peer presented an invalid device->user binding; storing entry without a user_id"
);
None
}
}
}
_ => None,
}
}
pub async fn handle_inviter_side(
conn: iroh::endpoint::Connection,
ctx: InviterCtx,
) -> anyhow::Result<()> {
let (mut send, recv) = conn.accept_bi().await?;
let mut reader = FrameReader::new(BufReader::new(recv), MAX_PAIR_FRAME);
let hello: RedeemerHello = match reader.next().await? {
Some(Inbound::Frame(v)) => match serde_json::from_value(v) {
Ok(h) => h,
Err(_) => return refuse(&mut send, REASON_MALFORMED, "malformed hello").await,
},
_ => return refuse(&mut send, REASON_MALFORMED, "malformed hello").await,
};
let tls_id = *conn.remote_id().as_bytes();
if tls_id != hello.redeemer_id {
return refuse(&mut send, REASON_ID_MISMATCH, "id mismatch").await;
}
let now = epoch_now();
match ctx.invites.try_redeem(&hello.secret, now) {
Redeem::Ok(invite) => {
let store_c = ctx.store.clone();
let nickname_c = hello.redeemer_nickname.clone();
let (existing, collides) = tokio::task::spawn_blocking(move || {
let existing = store_c.resolve(&tls_id)?;
let collides =
existing.is_none() && nickname_collision(&store_c, &nickname_c, &tls_id)?;
anyhow::Ok((existing, collides))
})
.await
.context("join nickname collision check")??;
if collides {
tracing::warn!(
nickname = %hello.redeemer_nickname,
"pairing refused: nickname collision"
);
let _ = send_reply(
&mut send,
&PairReply::Refused {
reason: REASON_REFUSED.into(),
},
)
.await;
return Ok(());
}
let nickname = existing
.as_ref()
.map_or_else(|| hello.redeemer_nickname.clone(), |e| e.nickname.clone());
let observed_addr = {
let addrs: Vec<iroh::TransportAddr> = conn
.paths()
.iter()
.map(|p| p.remote_addr().clone())
.collect();
if addrs.is_empty() {
None
} else {
serde_json::to_string(&iroh::EndpointAddr::from_parts(conn.remote_id(), addrs))
.ok()
}
};
let last_addr =
observed_addr.or_else(|| existing.as_ref().and_then(|e| e.last_addr.clone()));
let entry = PeerEntry {
endpoint_id: tls_id,
nickname: nickname.clone(),
services: existing
.as_ref()
.map(|e| e.services.clone())
.unwrap_or_default(),
paired_at: existing
.as_ref()
.and_then(|e| e.paired_at.clone())
.or_else(|| Some(now.to_string())),
user_id: verified_user_id(&hello.user_pk, &hello.binding_sig, &tls_id)
.or_else(|| existing.and_then(|e| e.user_id)),
last_addr,
};
let principal = entry
.user_id
.clone()
.unwrap_or_else(|| mcpmesh_net::EndpointId::from_bytes(tls_id).principal());
let store2 = ctx.store.clone();
tokio::task::spawn_blocking(move || store2.add(entry))
.await
.context("join pair store write")??;
(ctx.grant)(principal, nickname.clone(), invite.services.clone()).await?;
let sas = short_auth_code(&invite.inviter_id, &tls_id, &hello.secret);
tracing::info!(peer = %nickname, code = %sas, "paired");
(ctx.record_pairing)(nickname, sas, now);
let (inviter_pk, inviter_sig) = match ctx.self_binding {
Some(b) => (Some(b.user_pk), Some(b.sig)),
None => (None, None),
};
let _ = send_reply(
&mut send,
&PairReply::Ok {
inviter_id: invite.inviter_id,
inviter_nickname: invite.nickname.clone(),
user_pk: inviter_pk,
binding_sig: inviter_sig,
},
)
.await;
Ok(())
}
other => {
tracing::info!(outcome = ?other, "pair attempt refused");
let _ = send_reply(
&mut send,
&PairReply::Refused {
reason: REASON_REFUSED.into(),
},
)
.await;
Ok(())
}
}
}
async fn refuse(
send: &mut iroh::endpoint::SendStream,
reason: &str,
log: &str,
) -> anyhow::Result<()> {
tracing::info!("pair attempt refused: {log}");
let _ = send_reply(
send,
&PairReply::Refused {
reason: reason.into(),
},
)
.await;
Ok(())
}
async fn send_reply(
send: &mut iroh::endpoint::SendStream,
reply: &PairReply,
) -> anyhow::Result<()> {
write_frame(send, &serde_json::to_value(reply)?).await?;
let _ = send.finish();
let _ = send.stopped().await;
Ok(())
}
pub async fn redeem_invite(
endpoint: iroh::Endpoint,
self_nickname: String,
invite_line: String,
store: Arc<PeerStore>,
self_binding: Option<SelfBinding>,
) -> anyhow::Result<PairResult> {
let invite = Invite::decode(&invite_line)?;
if invite.expires_at_epoch < epoch_now() {
bail!("invite expired");
}
if let Some(conflict) = nickname_squat(&store, &invite.nickname, &invite.inviter_id)? {
bail!(
"this invite asks to be called '{}', but {conflict} \
Ask them for an invite suggesting a different name.",
invite.nickname,
);
}
let addr: iroh::EndpointAddr = serde_json::from_str(&invite.inviter_addr_json)
.context("invite carries an undecodable inviter address")?;
let conn = endpoint
.connect(addr, mcpmesh_net::ALPN_PAIR)
.await
.context("could not dial the inviter's machine")?;
if *conn.remote_id().as_bytes() != invite.inviter_id {
bail!("inviter id mismatch — refusing (address-swap defense)");
}
let (mut send, recv) = conn.open_bi().await.context("open the pairing bi-stream")?;
let (redeemer_pk, redeemer_sig) = match self_binding {
Some(b) => (Some(b.user_pk), Some(b.sig)),
None => (None, None),
};
let hello = RedeemerHello {
secret: invite.secret,
redeemer_id: *endpoint.id().as_bytes(),
redeemer_nickname: self_nickname,
user_pk: redeemer_pk,
binding_sig: redeemer_sig,
};
write_frame(&mut send, &serde_json::to_value(&hello)?)
.await
.context("send the pairing hello")?;
let mut reader = FrameReader::new(BufReader::new(recv), MAX_PAIR_FRAME);
let reply: PairReply = match reader.next().await? {
Some(Inbound::Frame(v)) => {
serde_json::from_value(v).context("inviter reply is not a PairReply")?
}
_ => bail!("no reply from the inviter (connection closed before a reply)"),
};
let inviter_user_id = match &reply {
PairReply::Refused { reason } => bail!("pairing refused: {reason}"),
PairReply::Ok {
user_pk,
binding_sig,
..
} => verified_user_id(user_pk, binding_sig, &invite.inviter_id),
};
let peer_user_id = inviter_user_id.clone();
let inviter_id = invite.inviter_id;
let nickname = invite.nickname.clone();
let granted = invite.services.clone();
let paired_at = Some(epoch_now().to_string());
let last_addr = Some(invite.inviter_addr_json.clone());
tokio::task::spawn_blocking(move || {
let existing = store.resolve(&inviter_id)?;
let mut services = existing
.as_ref()
.map(|e| e.services.clone())
.unwrap_or_default();
for svc in granted {
if !services.contains(&svc) {
services.push(svc);
}
}
store.add(PeerEntry {
endpoint_id: inviter_id,
nickname,
services,
paired_at,
user_id: inviter_user_id.or_else(|| existing.and_then(|e| e.user_id)),
last_addr,
})
})
.await
.context("join redeemer store write")??;
let self_id = *endpoint.id().as_bytes();
let sas_code = short_auth_code(&invite.inviter_id, &self_id, &invite.secret);
Ok(PairResult {
peer_nickname: invite.nickname,
sas_code,
services: invite.services,
app_label: invite.app_label,
peer_user_id,
})
}
fn nickname_collision(
store: &PeerStore,
nickname: &str,
tls_id: &[u8; 32],
) -> anyhow::Result<bool> {
Ok(store
.list()?
.into_iter()
.any(|e| e.nickname == nickname && &e.endpoint_id != tls_id))
}
fn nickname_squat(
store: &PeerStore,
nickname: &str,
inviter_id: &[u8; 32],
) -> anyhow::Result<Option<String>> {
let clashes = store
.list()?
.into_iter()
.any(|e| e.nickname == nickname && &e.endpoint_id != inviter_id);
Ok(clashes.then(|| {
"you already use that name for a different peer — \
accepting it would make your own dials to that name ambiguous. \
Unpair the existing peer first if you no longer need it."
.to_string()
}))
}