fix(call-to-play): key actors by stable peer identity

Participant maps and creator authorization previously used display names, so
two peers left at the default Commander name collapsed into one participant
and could exercise each other's creator controls through the normal client.

Carry a stable actor_id separately from actor_name. The peer overwrites actor_id
on every local publish, and live event envelopes are accepted only when the
known peer, source, and event actor match. The frontend keys participants and
authorization by actor_id while retaining actor_name for display. This follows
the trusted-LAN model and is not cryptographic authentication against a hostile
peer.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed, 21 tests
- `just build` -- passed
- `just peer-cli-tests S48` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-07-21 22:40:59 +02:00
parent 0f53bc4b78
commit 29eacabcc0
19 changed files with 197 additions and 69 deletions
+7
View File
@@ -54,6 +54,13 @@ applied to that history, sent to the UI, and broadcast to every currently known
peer. An incoming live event is applied once and sent to the UI without being
rebroadcast, which prevents forwarding loops.
Actors are keyed by the peer's stable ID and carry a separate display name. The
origin peer overwrites the actor ID on local actions, and live-event envelopes
must match the known sending peer. This prevents duplicate default usernames
from merging participants and protects creator controls from other normal
clients. It is not authentication against a hostile LAN peer; the QUIC setup
uses the project's trusted-LAN identity model.
`Hello` and `HelloAck` include each side's event history. This lets peers that
join after a call was created reconstruct the same nominations, responses,
RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream
+12 -5
View File
@@ -54,8 +54,9 @@ impl CallToPlayStore {
pub(crate) async fn publish(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
event: CallToPlayEvent,
mut event: CallToPlayEvent,
) {
event.actor_id.clone_from(ctx.peer_id.as_ref());
match ctx.call_to_play.write().await.insert(event.clone()) {
Ok(false) => return,
Err(err) => {
@@ -71,11 +72,15 @@ pub(crate) async fn publish(
);
let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses();
let peer_id = ctx.peer_id.clone();
ctx.task_tracker.spawn(async move {
let deliveries = peer_addresses.into_iter().map(|peer_addr| {
let event = event.clone();
let peer_id = peer_id.clone();
async move {
if let Err(err) = send_call_to_play_events(peer_addr, vec![event]).await {
if let Err(err) =
send_call_to_play_events(peer_addr, peer_id.as_ref(), vec![event]).await
{
log::warn!("Failed to send Call to Play event to {peer_addr}: {err}");
}
}
@@ -87,7 +92,8 @@ pub(crate) async fn publish(
fn validate_event(event: &CallToPlayEvent) -> Result<(), &'static str> {
validate_nonempty(&event.id, MAX_ID_CHARS, "invalid event id")?;
validate_nonempty(&event.call_id, MAX_ID_CHARS, "invalid call id")?;
validate_nonempty(&event.actor, MAX_USERNAME_CHARS, "invalid actor")?;
validate_nonempty(&event.actor_id, MAX_ID_CHARS, "invalid actor id")?;
validate_nonempty(&event.actor_name, MAX_USERNAME_CHARS, "invalid actor name")?;
if event.at <= 0 {
return Err("invalid event timestamp");
}
@@ -154,7 +160,8 @@ mod tests {
CallToPlayEvent {
id: id.to_string(),
call_id: "call-1".to_string(),
actor: "Alice".to_string(),
actor_id: "peer-alice".to_string(),
actor_name: "Alice".to_string(),
at: 1_000,
action: CallToPlayAction::Create {
game_id: "game-1".to_string(),
@@ -212,7 +219,7 @@ mod tests {
.insert(create_event("event-1"))
.expect("valid event should be inserted");
let mut invalid = create_event("invalid");
invalid.actor.clear();
invalid.actor_name.clear();
let accepted = store.insert_all(vec![
create_event("event-1"),
+9 -1
View File
@@ -175,9 +175,17 @@ pub async fn send_goodbye(peer_addr: SocketAddr, peer_id: String) -> eyre::Resul
pub async fn send_call_to_play_events(
peer_addr: SocketAddr,
peer_id: &str,
events: Vec<CallToPlayEvent>,
) -> eyre::Result<()> {
send_oneway_request(peer_addr, Request::CallToPlayEvents { events }).await
send_oneway_request(
peer_addr,
Request::CallToPlayEvents {
peer_id: peer_id.to_string(),
events,
},
)
.await
}
/// Requests game file details from a peer.
@@ -313,7 +313,8 @@ mod tests {
CallToPlayEvent {
id: "event-1".to_string(),
call_id: "call-1".to_string(),
actor: "Alice".to_string(),
actor_id: "peer-alice".to_string(),
actor_name: "Alice".to_string(),
at: 1_000,
action: CallToPlayAction::Create {
game_id: "game".to_string(),
+39 -8
View File
@@ -90,14 +90,11 @@ async fn dispatch_request(
handle_library_delta(ctx, peer_id, delta).await;
framed_tx
}
Request::CallToPlayEvents { events: incoming } => {
let accepted = ctx.call_to_play.write().await.insert_all(incoming);
if !accepted.is_empty() {
events::send(
&ctx.tx_notify_ui,
crate::PeerEvent::CallToPlayEvents(accepted),
);
}
Request::CallToPlayEvents {
peer_id,
events: incoming,
} => {
handle_call_to_play_events(ctx, remote_addr, &peer_id, incoming).await;
framed_tx
}
Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await,
@@ -124,6 +121,40 @@ async fn dispatch_request(
}
}
async fn handle_call_to_play_events(
ctx: &PeerCtx,
remote_addr: Option<SocketAddr>,
peer_id: &str,
incoming: Vec<lanspread_proto::CallToPlayEvent>,
) {
let peer_id = peer_id.to_string();
let sender_matches = if let Some(remote_addr) = remote_addr {
ctx.peer_game_db
.read()
.await
.peer_addr(&peer_id)
.is_some_and(|listen_addr| listen_addr.ip() == remote_addr.ip())
} else {
false
};
if !sender_matches {
log::warn!("Ignoring Call to Play events from unverified peer {peer_id}");
return;
}
if incoming.iter().any(|event| event.actor_id != peer_id) {
log::warn!("Ignoring Call to Play events with an actor that does not match {peer_id}");
return;
}
let accepted = ctx.call_to_play.write().await.insert_all(incoming);
if !accepted.is_empty() {
events::send(
&ctx.tx_notify_ui,
crate::PeerEvent::CallToPlayEvents(accepted),
);
}
}
async fn note_peer_activity(ctx: &PeerCtx, remote_addr: Option<SocketAddr>) {
if let Some(addr) = remote_addr {
ctx.peer_game_db