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
This commit is contained in:
@@ -50,8 +50,17 @@ interface MutableNomination extends Nomination {
|
||||
messageIds: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CallToPlayViewEvent, 'author_id' | 'id'>): 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<CallToPlayAction, { Create: unknown }>['Create'];
|
||||
type RespondPayload = Extract<CallToPlayAction, { Respond: unknown }>['Respond'];
|
||||
@@ -135,7 +144,7 @@ export const reduceCallToPlayEvents = (
|
||||
const groupEvents = (
|
||||
input: ReadonlyArray<CallToPlayViewEvent>,
|
||||
): Map<string, CallToPlayViewEvent[]> => {
|
||||
const unique = new Map(input.map(event => [event.id, event]));
|
||||
const unique = new Map(input.map(event => [eventKeyOf(event), event]));
|
||||
const byCall = new Map<string, CallToPlayViewEvent[]>();
|
||||
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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user