-
-
Notifications
You must be signed in to change notification settings - Fork 2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <1389908+richvdh@users.noreply.github.com> * fix test --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
- Loading branch information
Showing
6 changed files
with
895 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
/* | ||
Copyright 2024 New Vector Ltd. | ||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only | ||
Please see LICENSE files in the repository root for full details. | ||
*/ | ||
|
||
.mx_UserIdentityWarning { | ||
/* 42px is the padding-left of .mx_MessageComposer_wrapper in res/css/views/rooms/_MessageComposer.pcss */ | ||
margin-left: calc(-42px + var(--RoomView_MessageList-padding)); | ||
|
||
.mx_UserIdentityWarning_row { | ||
display: flex; | ||
align-items: center; | ||
|
||
.mx_BaseAvatar { | ||
margin-left: var(--cpd-space-2x); | ||
} | ||
.mx_UserIdentityWarning_main { | ||
margin-left: var(--cpd-space-6x); | ||
flex-grow: 1; | ||
} | ||
} | ||
} | ||
|
||
.mx_MessageComposer.mx_MessageComposer--compact > .mx_UserIdentityWarning { | ||
margin-left: calc(-25px + var(--RoomView_MessageList-padding)); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,328 @@ | ||
/* | ||
Copyright 2024 New Vector Ltd. | ||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only | ||
Please see LICENSE files in the repository root for full details. | ||
*/ | ||
|
||
import React, { useCallback, useRef, useState } from "react"; | ||
import { EventType, KnownMembership, MatrixEvent, Room, RoomStateEvent, RoomMember } from "matrix-js-sdk/src/matrix"; | ||
import { CryptoApi, CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; | ||
import { logger } from "matrix-js-sdk/src/logger"; | ||
import { Button, Separator } from "@vector-im/compound-web"; | ||
|
||
import { _t } from "../../../languageHandler"; | ||
import MemberAvatar from "../avatars/MemberAvatar"; | ||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; | ||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; | ||
|
||
interface UserIdentityWarningProps { | ||
/** | ||
* The current room being viewed. | ||
*/ | ||
room: Room; | ||
/** | ||
* The ID of the room being viewed. This is used to ensure that the | ||
* component's state and references are cleared when the room changes. | ||
*/ | ||
key: string; | ||
} | ||
|
||
/** | ||
* Does the given user's identity need to be approved? | ||
*/ | ||
async function userNeedsApproval(crypto: CryptoApi, userId: string): Promise<boolean> { | ||
const verificationStatus = await crypto.getUserVerificationStatus(userId); | ||
return verificationStatus.needsUserApproval; | ||
} | ||
|
||
/** | ||
* Whether the component is uninitialised, is in the process of initialising, or | ||
* has completed initialising. | ||
*/ | ||
enum InitialisationStatus { | ||
Uninitialised, | ||
Initialising, | ||
Completed, | ||
} | ||
|
||
/** | ||
* Displays a banner warning when there is an issue with a user's identity. | ||
* | ||
* Warns when an unverified user's identity has changed, and gives the user a | ||
* button to acknowledge the change. | ||
*/ | ||
export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }) => { | ||
const cli = useMatrixClientContext(); | ||
const crypto = cli.getCrypto(); | ||
|
||
// The current room member that we are prompting the user to approve. | ||
// `undefined` means we are not currently showing a prompt. | ||
const [currentPrompt, setCurrentPrompt] = useState<RoomMember | undefined>(undefined); | ||
|
||
// Whether or not we've already initialised the component by loading the | ||
// room membership. | ||
const initialisedRef = useRef<InitialisationStatus>(InitialisationStatus.Uninitialised); | ||
// Which room members need their identity approved. | ||
const membersNeedingApprovalRef = useRef<Map<string, RoomMember>>(new Map()); | ||
// For each user, we assign a sequence number to each verification status | ||
// that we get, or fetch. | ||
// | ||
// Since fetching a verification status is asynchronous, we could get an | ||
// update in the middle of fetching the verification status, which could | ||
// mean that the status that we fetched is out of date. So if the current | ||
// sequence number is not the same as the sequence number when we started | ||
// the fetch, then we drop our fetched result, under the assumption that the | ||
// update that we received is the most up-to-date version. If it is in fact | ||
// not the most up-to-date version, then we should be receiving a new update | ||
// soon with the newer value, so it will fix itself in the end. | ||
// | ||
// We also assign a sequence number when the user leaves the room, in order | ||
// to prevent prompting about a user who leaves while we are fetching their | ||
// verification status. | ||
const verificationStatusSequencesRef = useRef<Map<string, number>>(new Map()); | ||
const incrementVerificationStatusSequence = (userId: string): number => { | ||
const verificationStatusSequences = verificationStatusSequencesRef.current; | ||
const value = verificationStatusSequences.get(userId); | ||
const newValue = value === undefined ? 1 : value + 1; | ||
verificationStatusSequences.set(userId, newValue); | ||
return newValue; | ||
}; | ||
|
||
// Update the current prompt. Select a new user if needed, or hide the | ||
// warning if we don't have anyone to warn about. | ||
const updateCurrentPrompt = useCallback((): undefined => { | ||
const membersNeedingApproval = membersNeedingApprovalRef.current; | ||
// We have to do this in a callback to `setCurrentPrompt` | ||
// because this function could have been called after an | ||
// `await`, and the `currentPrompt` that this function would | ||
// have may be outdated. | ||
setCurrentPrompt((currentPrompt) => { | ||
// If we're already displaying a warning, and that user still needs | ||
// approval, continue showing that user. | ||
if (currentPrompt && membersNeedingApproval.has(currentPrompt.userId)) return currentPrompt; | ||
|
||
if (membersNeedingApproval.size === 0) { | ||
return undefined; | ||
} | ||
|
||
// We pick the user with the smallest user ID. | ||
const keys = Array.from(membersNeedingApproval.keys()).sort((a, b) => a.localeCompare(b)); | ||
const selection = membersNeedingApproval.get(keys[0]!); | ||
return selection; | ||
}); | ||
}, []); | ||
|
||
// Add a user to the membersNeedingApproval map, and update the current | ||
// prompt if necessary. The user will only be added if they are actually a | ||
// member of the room. If they are not a member, this function will do | ||
// nothing. | ||
const addMemberNeedingApproval = useCallback( | ||
(userId: string, member?: RoomMember): void => { | ||
if (userId === cli.getUserId()) { | ||
// We always skip our own user, because we can't pin our own identity. | ||
return; | ||
} | ||
member = member ?? room.getMember(userId) ?? undefined; | ||
if (!member) return; | ||
|
||
membersNeedingApprovalRef.current.set(userId, member); | ||
// We only select the prompt if we are done initialising, | ||
// because we will select the prompt after we're done | ||
// initialising, and we want to start by displaying a warning | ||
// for the user with the smallest ID. | ||
if (initialisedRef.current === InitialisationStatus.Completed) { | ||
updateCurrentPrompt(); | ||
} | ||
}, | ||
[cli, room, updateCurrentPrompt], | ||
); | ||
|
||
// For each user in the list check if their identity needs approval, and if | ||
// so, add them to the membersNeedingApproval map and update the prompt if | ||
// needed. | ||
const addMembersWhoNeedApproval = useCallback( | ||
async (members: RoomMember[]): Promise<void> => { | ||
const verificationStatusSequences = verificationStatusSequencesRef.current; | ||
|
||
const promises: Promise<void>[] = []; | ||
|
||
for (const member of members) { | ||
const userId = member.userId; | ||
const sequenceNum = incrementVerificationStatusSequence(userId); | ||
promises.push( | ||
userNeedsApproval(crypto!, userId).then((needsApproval) => { | ||
if (needsApproval) { | ||
// Only actually update the list if we have the most | ||
// recent value. | ||
if (verificationStatusSequences.get(userId) === sequenceNum) { | ||
addMemberNeedingApproval(userId, member); | ||
} | ||
} | ||
}), | ||
); | ||
} | ||
|
||
await Promise.all(promises); | ||
}, | ||
[crypto, addMemberNeedingApproval], | ||
); | ||
|
||
// Remove a user from the membersNeedingApproval map, and update the current | ||
// prompt if necessary. | ||
const removeMemberNeedingApproval = useCallback( | ||
(userId: string): void => { | ||
membersNeedingApprovalRef.current.delete(userId); | ||
updateCurrentPrompt(); | ||
}, | ||
[updateCurrentPrompt], | ||
); | ||
|
||
// Initialise the component. Get the room members, check which ones need | ||
// their identity approved, and pick one to display. | ||
const loadMembers = useCallback(async (): Promise<void> => { | ||
if (!crypto || initialisedRef.current !== InitialisationStatus.Uninitialised) { | ||
return; | ||
} | ||
// If encryption is not enabled in the room, we don't need to do | ||
// anything. If encryption gets enabled later, we will retry, via | ||
// onRoomStateEvent. | ||
if (!(await crypto.isEncryptionEnabledInRoom(room.roomId))) { | ||
return; | ||
} | ||
initialisedRef.current = InitialisationStatus.Initialising; | ||
|
||
const members = await room.getEncryptionTargetMembers(); | ||
await addMembersWhoNeedApproval(members); | ||
|
||
updateCurrentPrompt(); | ||
initialisedRef.current = InitialisationStatus.Completed; | ||
}, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]); | ||
|
||
loadMembers().catch((e) => { | ||
logger.error("Error initialising UserIdentityWarning:", e); | ||
}); | ||
|
||
// When a user's verification status changes, we check if they need to be | ||
// added/removed from the set of members needing approval. | ||
const onUserVerificationStatusChanged = useCallback( | ||
(userId: string, verificationStatus: UserVerificationStatus): void => { | ||
// If we haven't started initialising, that means that we're in a | ||
// room where we don't need to display any warnings. | ||
if (initialisedRef.current === InitialisationStatus.Uninitialised) { | ||
return; | ||
} | ||
|
||
incrementVerificationStatusSequence(userId); | ||
|
||
if (verificationStatus.needsUserApproval) { | ||
addMemberNeedingApproval(userId); | ||
} else { | ||
removeMemberNeedingApproval(userId); | ||
} | ||
}, | ||
[addMemberNeedingApproval, removeMemberNeedingApproval], | ||
); | ||
useTypedEventEmitter(cli, CryptoEvent.UserTrustStatusChanged, onUserVerificationStatusChanged); | ||
|
||
// We watch for encryption events (since we only display warnings in | ||
// encrypted rooms), and for membership changes (since we only display | ||
// warnings for users in the room). | ||
const onRoomStateEvent = useCallback( | ||
async (event: MatrixEvent): Promise<void> => { | ||
if (!crypto || event.getRoomId() !== room.roomId) { | ||
return; | ||
} | ||
|
||
const eventType = event.getType(); | ||
if (eventType === EventType.RoomEncryption && event.getStateKey() === "") { | ||
// Room is now encrypted, so we can initialise the component. | ||
return loadMembers().catch((e) => { | ||
logger.error("Error initialising UserIdentityWarning:", e); | ||
}); | ||
} else if (eventType !== EventType.RoomMember) { | ||
return; | ||
} | ||
|
||
// We're processing an m.room.member event | ||
|
||
if (initialisedRef.current === InitialisationStatus.Uninitialised) { | ||
return; | ||
} | ||
|
||
const userId = event.getStateKey(); | ||
|
||
if (!userId) return; | ||
|
||
if ( | ||
event.getContent().membership === KnownMembership.Join || | ||
(event.getContent().membership === KnownMembership.Invite && room.shouldEncryptForInvitedMembers()) | ||
) { | ||
// Someone's membership changed and we will now encrypt to them. If | ||
// their identity needs approval, show a warning. | ||
const member = room.getMember(userId); | ||
if (member) { | ||
await addMembersWhoNeedApproval([member]).catch((e) => { | ||
logger.error("Error adding member in UserIdentityWarning:", e); | ||
}); | ||
} | ||
} else { | ||
// Someone's membership changed and we no longer encrypt to them. | ||
// If we're showing a warning about them, we don't need to any more. | ||
removeMemberNeedingApproval(userId); | ||
incrementVerificationStatusSequence(userId); | ||
} | ||
}, | ||
[crypto, room, addMembersWhoNeedApproval, removeMemberNeedingApproval, loadMembers], | ||
); | ||
useTypedEventEmitter(cli, RoomStateEvent.Events, onRoomStateEvent); | ||
|
||
if (!crypto || !currentPrompt) return null; | ||
|
||
const confirmIdentity = async (): Promise<void> => { | ||
await crypto.pinCurrentUserIdentity(currentPrompt.userId); | ||
}; | ||
|
||
return ( | ||
<div className="mx_UserIdentityWarning"> | ||
<Separator /> | ||
<div className="mx_UserIdentityWarning_row"> | ||
<MemberAvatar member={currentPrompt} title={currentPrompt.userId} size="30px" /> | ||
<span className="mx_UserIdentityWarning_main"> | ||
{currentPrompt.rawDisplayName === currentPrompt.userId | ||
? _t( | ||
"encryption|pinned_identity_changed_no_displayname", | ||
{ userId: currentPrompt.userId }, | ||
{ | ||
a: substituteATag, | ||
b: substituteBTag, | ||
}, | ||
) | ||
: _t( | ||
"encryption|pinned_identity_changed", | ||
{ displayName: currentPrompt.rawDisplayName, userId: currentPrompt.userId }, | ||
{ | ||
a: substituteATag, | ||
b: substituteBTag, | ||
}, | ||
)} | ||
</span> | ||
<Button kind="primary" size="sm" onClick={confirmIdentity}> | ||
{_t("action|ok")} | ||
</Button> | ||
</div> | ||
</div> | ||
); | ||
}; | ||
|
||
function substituteATag(sub: string): React.ReactNode { | ||
return ( | ||
<a href="https://element.io/help#encryption18" target="_blank" rel="noreferrer noopener"> | ||
{sub} | ||
</a> | ||
); | ||
} | ||
|
||
function substituteBTag(sub: string): React.ReactNode { | ||
return <b>{sub}</b>; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.