Skip to content

Commit c2ce7db

Browse files
uhoregrichvdh
andauthored
Display a warning when an unverified user's identity changes (#28211)
* display a warning when an unverified user's identity changes * use Compound and make comments into doc comments * refactor to use functional component * split into multiple hooks * apply minor changes from review * use Crypto API to determine if room is encrypted * apply changes from review * change initialisation status to a tri-state rather than a boolean * fix more race conditions, and apply changes from review * apply changes from review and switch to using counter for detecting races * Remove outdated comment Co-authored-by: Richard van der Hoff <[email protected]> * fix test --------- Co-authored-by: Richard van der Hoff <[email protected]>
1 parent affa4e5 commit c2ce7db

File tree

6 files changed

+895
-0
lines changed

6 files changed

+895
-0
lines changed

res/css/_components.pcss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@
319319
@import "./views/rooms/_ThirdPartyMemberInfo.pcss";
320320
@import "./views/rooms/_ThreadSummary.pcss";
321321
@import "./views/rooms/_TopUnreadMessagesBar.pcss";
322+
@import "./views/rooms/_UserIdentityWarning.pcss";
322323
@import "./views/rooms/_VoiceRecordComposerTile.pcss";
323324
@import "./views/rooms/_WhoIsTypingTile.pcss";
324325
@import "./views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss";
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
Copyright 2024 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
.mx_UserIdentityWarning {
9+
/* 42px is the padding-left of .mx_MessageComposer_wrapper in res/css/views/rooms/_MessageComposer.pcss */
10+
margin-left: calc(-42px + var(--RoomView_MessageList-padding));
11+
12+
.mx_UserIdentityWarning_row {
13+
display: flex;
14+
align-items: center;
15+
16+
.mx_BaseAvatar {
17+
margin-left: var(--cpd-space-2x);
18+
}
19+
.mx_UserIdentityWarning_main {
20+
margin-left: var(--cpd-space-6x);
21+
flex-grow: 1;
22+
}
23+
}
24+
}
25+
26+
.mx_MessageComposer.mx_MessageComposer--compact > .mx_UserIdentityWarning {
27+
margin-left: calc(-25px + var(--RoomView_MessageList-padding));
28+
}

src/components/views/rooms/MessageComposer.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import E2EIcon from "./E2EIcon";
3030
import SettingsStore from "../../../settings/SettingsStore";
3131
import { aboveLeftOf, MenuProps } from "../../structures/ContextMenu";
3232
import ReplyPreview from "./ReplyPreview";
33+
import { UserIdentityWarning } from "./UserIdentityWarning";
3334
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
3435
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
3536
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
@@ -669,6 +670,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
669670
<Tooltip open={isTooltipOpen} description={formatTimeLeft(secondsLeft)} placement="bottom">
670671
<div className={classes} ref={this.ref} role="region" aria-label={_t("a11y|message_composer")}>
671672
<div className="mx_MessageComposer_wrapper">
673+
<UserIdentityWarning room={this.props.room} key={this.props.room.roomId} />
672674
<ReplyPreview
673675
replyToEvent={this.props.replyToEvent}
674676
permalinkCreator={this.props.permalinkCreator}
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
/*
2+
Copyright 2024 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import React, { useCallback, useRef, useState } from "react";
9+
import { EventType, KnownMembership, MatrixEvent, Room, RoomStateEvent, RoomMember } from "matrix-js-sdk/src/matrix";
10+
import { CryptoApi, CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
11+
import { logger } from "matrix-js-sdk/src/logger";
12+
import { Button, Separator } from "@vector-im/compound-web";
13+
14+
import { _t } from "../../../languageHandler";
15+
import MemberAvatar from "../avatars/MemberAvatar";
16+
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
17+
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
18+
19+
interface UserIdentityWarningProps {
20+
/**
21+
* The current room being viewed.
22+
*/
23+
room: Room;
24+
/**
25+
* The ID of the room being viewed. This is used to ensure that the
26+
* component's state and references are cleared when the room changes.
27+
*/
28+
key: string;
29+
}
30+
31+
/**
32+
* Does the given user's identity need to be approved?
33+
*/
34+
async function userNeedsApproval(crypto: CryptoApi, userId: string): Promise<boolean> {
35+
const verificationStatus = await crypto.getUserVerificationStatus(userId);
36+
return verificationStatus.needsUserApproval;
37+
}
38+
39+
/**
40+
* Whether the component is uninitialised, is in the process of initialising, or
41+
* has completed initialising.
42+
*/
43+
enum InitialisationStatus {
44+
Uninitialised,
45+
Initialising,
46+
Completed,
47+
}
48+
49+
/**
50+
* Displays a banner warning when there is an issue with a user's identity.
51+
*
52+
* Warns when an unverified user's identity has changed, and gives the user a
53+
* button to acknowledge the change.
54+
*/
55+
export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }) => {
56+
const cli = useMatrixClientContext();
57+
const crypto = cli.getCrypto();
58+
59+
// The current room member that we are prompting the user to approve.
60+
// `undefined` means we are not currently showing a prompt.
61+
const [currentPrompt, setCurrentPrompt] = useState<RoomMember | undefined>(undefined);
62+
63+
// Whether or not we've already initialised the component by loading the
64+
// room membership.
65+
const initialisedRef = useRef<InitialisationStatus>(InitialisationStatus.Uninitialised);
66+
// Which room members need their identity approved.
67+
const membersNeedingApprovalRef = useRef<Map<string, RoomMember>>(new Map());
68+
// For each user, we assign a sequence number to each verification status
69+
// that we get, or fetch.
70+
//
71+
// Since fetching a verification status is asynchronous, we could get an
72+
// update in the middle of fetching the verification status, which could
73+
// mean that the status that we fetched is out of date. So if the current
74+
// sequence number is not the same as the sequence number when we started
75+
// the fetch, then we drop our fetched result, under the assumption that the
76+
// update that we received is the most up-to-date version. If it is in fact
77+
// not the most up-to-date version, then we should be receiving a new update
78+
// soon with the newer value, so it will fix itself in the end.
79+
//
80+
// We also assign a sequence number when the user leaves the room, in order
81+
// to prevent prompting about a user who leaves while we are fetching their
82+
// verification status.
83+
const verificationStatusSequencesRef = useRef<Map<string, number>>(new Map());
84+
const incrementVerificationStatusSequence = (userId: string): number => {
85+
const verificationStatusSequences = verificationStatusSequencesRef.current;
86+
const value = verificationStatusSequences.get(userId);
87+
const newValue = value === undefined ? 1 : value + 1;
88+
verificationStatusSequences.set(userId, newValue);
89+
return newValue;
90+
};
91+
92+
// Update the current prompt. Select a new user if needed, or hide the
93+
// warning if we don't have anyone to warn about.
94+
const updateCurrentPrompt = useCallback((): undefined => {
95+
const membersNeedingApproval = membersNeedingApprovalRef.current;
96+
// We have to do this in a callback to `setCurrentPrompt`
97+
// because this function could have been called after an
98+
// `await`, and the `currentPrompt` that this function would
99+
// have may be outdated.
100+
setCurrentPrompt((currentPrompt) => {
101+
// If we're already displaying a warning, and that user still needs
102+
// approval, continue showing that user.
103+
if (currentPrompt && membersNeedingApproval.has(currentPrompt.userId)) return currentPrompt;
104+
105+
if (membersNeedingApproval.size === 0) {
106+
return undefined;
107+
}
108+
109+
// We pick the user with the smallest user ID.
110+
const keys = Array.from(membersNeedingApproval.keys()).sort((a, b) => a.localeCompare(b));
111+
const selection = membersNeedingApproval.get(keys[0]!);
112+
return selection;
113+
});
114+
}, []);
115+
116+
// Add a user to the membersNeedingApproval map, and update the current
117+
// prompt if necessary. The user will only be added if they are actually a
118+
// member of the room. If they are not a member, this function will do
119+
// nothing.
120+
const addMemberNeedingApproval = useCallback(
121+
(userId: string, member?: RoomMember): void => {
122+
if (userId === cli.getUserId()) {
123+
// We always skip our own user, because we can't pin our own identity.
124+
return;
125+
}
126+
member = member ?? room.getMember(userId) ?? undefined;
127+
if (!member) return;
128+
129+
membersNeedingApprovalRef.current.set(userId, member);
130+
// We only select the prompt if we are done initialising,
131+
// because we will select the prompt after we're done
132+
// initialising, and we want to start by displaying a warning
133+
// for the user with the smallest ID.
134+
if (initialisedRef.current === InitialisationStatus.Completed) {
135+
updateCurrentPrompt();
136+
}
137+
},
138+
[cli, room, updateCurrentPrompt],
139+
);
140+
141+
// For each user in the list check if their identity needs approval, and if
142+
// so, add them to the membersNeedingApproval map and update the prompt if
143+
// needed.
144+
const addMembersWhoNeedApproval = useCallback(
145+
async (members: RoomMember[]): Promise<void> => {
146+
const verificationStatusSequences = verificationStatusSequencesRef.current;
147+
148+
const promises: Promise<void>[] = [];
149+
150+
for (const member of members) {
151+
const userId = member.userId;
152+
const sequenceNum = incrementVerificationStatusSequence(userId);
153+
promises.push(
154+
userNeedsApproval(crypto!, userId).then((needsApproval) => {
155+
if (needsApproval) {
156+
// Only actually update the list if we have the most
157+
// recent value.
158+
if (verificationStatusSequences.get(userId) === sequenceNum) {
159+
addMemberNeedingApproval(userId, member);
160+
}
161+
}
162+
}),
163+
);
164+
}
165+
166+
await Promise.all(promises);
167+
},
168+
[crypto, addMemberNeedingApproval],
169+
);
170+
171+
// Remove a user from the membersNeedingApproval map, and update the current
172+
// prompt if necessary.
173+
const removeMemberNeedingApproval = useCallback(
174+
(userId: string): void => {
175+
membersNeedingApprovalRef.current.delete(userId);
176+
updateCurrentPrompt();
177+
},
178+
[updateCurrentPrompt],
179+
);
180+
181+
// Initialise the component. Get the room members, check which ones need
182+
// their identity approved, and pick one to display.
183+
const loadMembers = useCallback(async (): Promise<void> => {
184+
if (!crypto || initialisedRef.current !== InitialisationStatus.Uninitialised) {
185+
return;
186+
}
187+
// If encryption is not enabled in the room, we don't need to do
188+
// anything. If encryption gets enabled later, we will retry, via
189+
// onRoomStateEvent.
190+
if (!(await crypto.isEncryptionEnabledInRoom(room.roomId))) {
191+
return;
192+
}
193+
initialisedRef.current = InitialisationStatus.Initialising;
194+
195+
const members = await room.getEncryptionTargetMembers();
196+
await addMembersWhoNeedApproval(members);
197+
198+
updateCurrentPrompt();
199+
initialisedRef.current = InitialisationStatus.Completed;
200+
}, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]);
201+
202+
loadMembers().catch((e) => {
203+
logger.error("Error initialising UserIdentityWarning:", e);
204+
});
205+
206+
// When a user's verification status changes, we check if they need to be
207+
// added/removed from the set of members needing approval.
208+
const onUserVerificationStatusChanged = useCallback(
209+
(userId: string, verificationStatus: UserVerificationStatus): void => {
210+
// If we haven't started initialising, that means that we're in a
211+
// room where we don't need to display any warnings.
212+
if (initialisedRef.current === InitialisationStatus.Uninitialised) {
213+
return;
214+
}
215+
216+
incrementVerificationStatusSequence(userId);
217+
218+
if (verificationStatus.needsUserApproval) {
219+
addMemberNeedingApproval(userId);
220+
} else {
221+
removeMemberNeedingApproval(userId);
222+
}
223+
},
224+
[addMemberNeedingApproval, removeMemberNeedingApproval],
225+
);
226+
useTypedEventEmitter(cli, CryptoEvent.UserTrustStatusChanged, onUserVerificationStatusChanged);
227+
228+
// We watch for encryption events (since we only display warnings in
229+
// encrypted rooms), and for membership changes (since we only display
230+
// warnings for users in the room).
231+
const onRoomStateEvent = useCallback(
232+
async (event: MatrixEvent): Promise<void> => {
233+
if (!crypto || event.getRoomId() !== room.roomId) {
234+
return;
235+
}
236+
237+
const eventType = event.getType();
238+
if (eventType === EventType.RoomEncryption && event.getStateKey() === "") {
239+
// Room is now encrypted, so we can initialise the component.
240+
return loadMembers().catch((e) => {
241+
logger.error("Error initialising UserIdentityWarning:", e);
242+
});
243+
} else if (eventType !== EventType.RoomMember) {
244+
return;
245+
}
246+
247+
// We're processing an m.room.member event
248+
249+
if (initialisedRef.current === InitialisationStatus.Uninitialised) {
250+
return;
251+
}
252+
253+
const userId = event.getStateKey();
254+
255+
if (!userId) return;
256+
257+
if (
258+
event.getContent().membership === KnownMembership.Join ||
259+
(event.getContent().membership === KnownMembership.Invite && room.shouldEncryptForInvitedMembers())
260+
) {
261+
// Someone's membership changed and we will now encrypt to them. If
262+
// their identity needs approval, show a warning.
263+
const member = room.getMember(userId);
264+
if (member) {
265+
await addMembersWhoNeedApproval([member]).catch((e) => {
266+
logger.error("Error adding member in UserIdentityWarning:", e);
267+
});
268+
}
269+
} else {
270+
// Someone's membership changed and we no longer encrypt to them.
271+
// If we're showing a warning about them, we don't need to any more.
272+
removeMemberNeedingApproval(userId);
273+
incrementVerificationStatusSequence(userId);
274+
}
275+
},
276+
[crypto, room, addMembersWhoNeedApproval, removeMemberNeedingApproval, loadMembers],
277+
);
278+
useTypedEventEmitter(cli, RoomStateEvent.Events, onRoomStateEvent);
279+
280+
if (!crypto || !currentPrompt) return null;
281+
282+
const confirmIdentity = async (): Promise<void> => {
283+
await crypto.pinCurrentUserIdentity(currentPrompt.userId);
284+
};
285+
286+
return (
287+
<div className="mx_UserIdentityWarning">
288+
<Separator />
289+
<div className="mx_UserIdentityWarning_row">
290+
<MemberAvatar member={currentPrompt} title={currentPrompt.userId} size="30px" />
291+
<span className="mx_UserIdentityWarning_main">
292+
{currentPrompt.rawDisplayName === currentPrompt.userId
293+
? _t(
294+
"encryption|pinned_identity_changed_no_displayname",
295+
{ userId: currentPrompt.userId },
296+
{
297+
a: substituteATag,
298+
b: substituteBTag,
299+
},
300+
)
301+
: _t(
302+
"encryption|pinned_identity_changed",
303+
{ displayName: currentPrompt.rawDisplayName, userId: currentPrompt.userId },
304+
{
305+
a: substituteATag,
306+
b: substituteBTag,
307+
},
308+
)}
309+
</span>
310+
<Button kind="primary" size="sm" onClick={confirmIdentity}>
311+
{_t("action|ok")}
312+
</Button>
313+
</div>
314+
</div>
315+
);
316+
};
317+
318+
function substituteATag(sub: string): React.ReactNode {
319+
return (
320+
<a href="https://element.io/help#encryption18" target="_blank" rel="noreferrer noopener">
321+
{sub}
322+
</a>
323+
);
324+
}
325+
326+
function substituteBTag(sub: string): React.ReactNode {
327+
return <b>{sub}</b>;
328+
}

src/i18n/strings/en_EN.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,8 @@
905905
"warning": "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings."
906906
},
907907
"not_supported": "<not supported>",
908+
"pinned_identity_changed": "%(displayName)s's (<b>%(userId)s</b>) identity appears to have changed. <a>Learn more</a>",
909+
"pinned_identity_changed_no_displayname": "<b>%(userId)s</b>'s identity appears to have changed. <a>Learn more</a>",
908910
"recovery_method_removed": {
909911
"description_1": "This session has detected that your Security Phrase and key for Secure Messages have been removed.",
910912
"description_2": "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.",

0 commit comments

Comments
 (0)