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>;
|
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 =>
|
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 CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create'];
|
||||||
type RespondPayload = Extract<CallToPlayAction, { Respond: unknown }>['Respond'];
|
type RespondPayload = Extract<CallToPlayAction, { Respond: unknown }>['Respond'];
|
||||||
@@ -135,7 +144,7 @@ export const reduceCallToPlayEvents = (
|
|||||||
const groupEvents = (
|
const groupEvents = (
|
||||||
input: ReadonlyArray<CallToPlayViewEvent>,
|
input: ReadonlyArray<CallToPlayViewEvent>,
|
||||||
): Map<string, 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[]>();
|
const byCall = new Map<string, CallToPlayViewEvent[]>();
|
||||||
for (const event of unique.values()) {
|
for (const event of unique.values()) {
|
||||||
const events = byCall.get(event.call_id) ?? [];
|
const events = byCall.get(event.call_id) ?? [];
|
||||||
@@ -218,10 +227,11 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayViewEvent):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = messagePayload(action);
|
const message = messagePayload(action);
|
||||||
if (message && !nomination.messageIds.has(event.id)) {
|
const messageKey = eventKeyOf(event);
|
||||||
nomination.messageIds.add(event.id);
|
if (message && !nomination.messageIds.has(messageKey)) {
|
||||||
|
nomination.messageIds.add(messageKey);
|
||||||
nomination.messages.push({
|
nomination.messages.push({
|
||||||
id: event.id,
|
id: messageKey,
|
||||||
fromId: event.author_id,
|
fromId: event.author_id,
|
||||||
from: event.author_name,
|
from: event.author_name,
|
||||||
text: message.text,
|
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', () => {
|
Deno.test('play-now call starts with its creator ready', () => {
|
||||||
const [nomination] = reduceCallToPlayEvents([create()], NOW);
|
const [nomination] = reduceCallToPlayEvents([create()], NOW);
|
||||||
assert(nomination, 'call should exist');
|
assert(nomination, 'call should exist');
|
||||||
|
|||||||
Reference in New Issue
Block a user