From 43b69a0f879bc51cf36d05dea12da982e7590135 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Wed, 2 Sep 2026 22:37:40 +0200 Subject: [PATCH] fix(frontend): scope Call-to-Play event keys by author Security audit finding Codex #14 ("cross-author event-ID collisions can suppress Call-to-Play entries"). The Rust side guarantees that an event nonce is unique within one authenticated author's history and preserves `author_id` on every projected event, but the frontend reducer deduplicated the merged view with `new Map(events.map(e => [e.id, e]))` and tracked chat messages by `event.id` alone. A peer could therefore publish, say, a Respond event reusing the nonce of another user's Create event and make that call vanish from every viewer, or shadow other users' chat messages. `eventKeyOf` now builds `author_id + NUL + id` and is used for view deduplication, event ordering ties, message deduplication and the message id that CtpChat uses as its React key. Nomination ids (`call_id`) were already creator scoped and are unchanged. Test plan: `just frontend-test`. The new test feeds a Create from Alice and a Respond from Bob sharing one nonce and expects both to apply, then two same-nonce messages from different authors and expects two distinct messages. Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg --- .../src/lib/callToPlay.ts | 20 +++++++++++++----- .../tests/callToPlay.test.ts | 21 +++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts b/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts index d57e374..c262cdb 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts @@ -50,8 +50,17 @@ interface MutableNomination extends Nomination { messageIds: Set; } +/** + * Event nonces are only unique within one author's history: the backend + * validates them per authenticated author, so two authors may legitimately + * (or deliberately) publish events with the same nonce. Every place that + * deduplicates or keys events therefore scopes the nonce by author. + */ +export const eventKeyOf = (event: Pick): string => + `${event.author_id}\u0000${event.id}`; + const compareEvents = (a: CallToPlayViewEvent, b: CallToPlayViewEvent): number => - a.at - b.at || a.id.localeCompare(b.id); + a.at - b.at || eventKeyOf(a).localeCompare(eventKeyOf(b)); type CreatePayload = Extract['Create']; type RespondPayload = Extract['Respond']; @@ -135,7 +144,7 @@ export const reduceCallToPlayEvents = ( const groupEvents = ( input: ReadonlyArray, ): Map => { - const unique = new Map(input.map(event => [event.id, event])); + const unique = new Map(input.map(event => [eventKeyOf(event), event])); const byCall = new Map(); for (const event of unique.values()) { const events = byCall.get(event.call_id) ?? []; @@ -218,10 +227,11 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayViewEvent): } const message = messagePayload(action); - if (message && !nomination.messageIds.has(event.id)) { - nomination.messageIds.add(event.id); + const messageKey = eventKeyOf(event); + if (message && !nomination.messageIds.has(messageKey)) { + nomination.messageIds.add(messageKey); nomination.messages.push({ - id: event.id, + id: messageKey, fromId: event.author_id, from: event.author_name, text: message.text, diff --git a/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts b/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts index 7c0a98c..a3bfe4d 100644 --- a/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts +++ b/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts @@ -54,6 +54,27 @@ const create = ( }, }); +Deno.test('events from different authors never collapse on a shared nonce', () => { + const respond = event('create', 'Bob', { Respond: { ready_at: null } }, NOW + 1); + const [nomination] = reduceCallToPlayEvents([create(), respond], NOW + 2); + assert(nomination, 'the creator event must survive a colliding nonce from another author'); + assertEquals(nomination.creator, 'Alice', 'creator'); + assertEquals(Object.keys(nomination.participants).length, 2, 'both participants applied'); + + const aliceMessage = event('m1', 'Alice', { SendMessage: { text: 'hi' } }, NOW + 3); + const bobMessage = event('m1', 'Bob', { SendMessage: { text: 'yo' } }, NOW + 4); + const [withMessages] = reduceCallToPlayEvents( + [create(), respond, aliceMessage, bobMessage], + NOW + 5, + ); + assert(withMessages, 'call should exist'); + assertEquals(withMessages.messages.length, 2, 'messages with a shared nonce are distinct'); + assert( + withMessages.messages[0].id !== withMessages.messages[1].id, + 'message keys are author scoped', + ); +}); + Deno.test('play-now call starts with its creator ready', () => { const [nomination] = reduceCallToPlayEvents([create()], NOW); assert(nomination, 'call should exist');