diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index c0707fba260..4481e4f6038 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -38,7 +38,6 @@ import SettingsStore from "../../settings/SettingsStore"; import { SettingLevel } from "../../settings/SettingLevel"; import ResizeHandle from "../views/elements/ResizeHandle"; import { CollapseDistributor, Resizer } from "../../resizer"; -import MatrixClientContext from "../../contexts/MatrixClientContext"; import ResizeNotifier from "../../utils/ResizeNotifier"; import PlatformPeg from "../../PlatformPeg"; import { DefaultTagID } from "../../stores/room-list/models"; @@ -75,6 +74,7 @@ import { UserOnboardingPage } from "../views/user-onboarding/UserOnboardingPage" import { PipContainer } from "./PipContainer"; import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushRules"; import { ConfigOptions } from "../../SdkConfig"; +import { MatrixClientContextProvider } from "./MatrixClientContextProvider"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -672,7 +672,7 @@ class LoggedInView extends React.Component { }); return ( - +
{ {audioFeedArraysForCalls} - + ); } } diff --git a/src/components/structures/MatrixClientContextProvider.tsx b/src/components/structures/MatrixClientContextProvider.tsx new file mode 100644 index 00000000000..2b675f85558 --- /dev/null +++ b/src/components/structures/MatrixClientContextProvider.tsx @@ -0,0 +1,104 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { PropsWithChildren, useEffect, useState } from "react"; +import { CryptoEvent, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; + +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { useEventEmitter } from "../../hooks/useEventEmitter"; +import { LocalDeviceVerificationStateContext } from "../../contexts/LocalDeviceVerificationStateContext"; + +/** + * A React hook whose value is whether the local device has been "verified". + * + * Figuring out if we are verified is an async operation, so on the first render this always returns `false`, but + * fires off a background job to update a state variable. It also registers an event listener to update the state + * variable changes. + * + * @param client - Matrix client. + * @returns A boolean which is `true` if the local device has been verified. + * + * @remarks + * + * Some notes on implementation. + * + * It turns out "is this device verified?" isn't a question that is easy to answer as you might think. + * + * Roughly speaking, it normally means "do we believe this device actually belongs to the person it claims to belong + * to", and that data is available via `getDeviceVerificationStatus().isVerified()`. However, the problem is that for + * the local device, that "do we believe..." question is trivially true, and `isVerified()` always returns true. + * + * Instead, when we're talking about the local device, what we really mean is one of: + * * "have we completed a verification dance (either interactive verification with a device with access to the + * cross-signing secrets, or typing in the 4S key)?", or + * * "will other devices consider this one to be verified?" + * + * (The first is generally required but not sufficient for the second to be true.) + * + * The second question basically amounts to "has this device been signed by our cross-signing key". So one option here + * is to use `getDeviceVerificationStatus().isCrossSigningVerified()`. That might work, but it's a bit annoying because + * it needs a `/keys/query` request to complete after the actual verification process completes. + * + * A slightly less rigorous check is just to find out if we have validated our own public cross-signing keys. If we + * have, it's a good indication that we've at least completed a verification dance -- and hopefully, during that dance, + * a cross-signature of our own device was published. And it's also easy to monitor via `UserTrustStatusChanged` events. + * + * Sooo: TL;DR: `getUserVerificationStatus()` is a good proxy for "is the local device verified?". + */ +function useLocalVerificationState(client: MatrixClient): boolean { + const [value, setValue] = useState(false); + + // On the first render, initialise the state variable + useEffect(() => { + const userId = client.getUserId(); + if (!userId) return; + const crypto = client.getCrypto(); + crypto?.getUserVerificationStatus(userId).then( + (verificationStatus) => setValue(verificationStatus.isCrossSigningVerified()), + (error) => logger.error("Error fetching verification status", error), + ); + }, [client]); + + // Update the value whenever our own trust status changes. + useEventEmitter(client, CryptoEvent.UserTrustStatusChanged, (userId, verificationStatus) => { + if (userId === client.getUserId()) { + setValue(verificationStatus.isCrossSigningVerified()); + } + }); + + return value; +} + +interface Props { + /** Matrix client, which is exposed to all child components via {@link MatrixClientContext}. */ + client: MatrixClient; +} + +/** + * A React component which exposes a {@link MatrixClientContext} and a {@link LocalDeviceVerificationStateContext} + * to its children. + */ +export function MatrixClientContextProvider(props: PropsWithChildren): React.JSX.Element { + const verificationState = useLocalVerificationState(props.client); + return ( + + + {props.children} + + + ); +} diff --git a/src/contexts/LocalDeviceVerificationStateContext.ts b/src/contexts/LocalDeviceVerificationStateContext.ts new file mode 100644 index 00000000000..4f0d7fe5023 --- /dev/null +++ b/src/contexts/LocalDeviceVerificationStateContext.ts @@ -0,0 +1,27 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { createContext } from "react"; + +/** + * React context whose value is whether the local device has been verified. + * + * (Specifically, this is true if we have done enough verification to confirm that the published public cross-signing + * keys are genuine -- which normally means that we or another device will have published a signature of this device.) + * + * This context is available to all components under {@link LoggedInView}, via {@link MatrixClientContextProvider}. + */ +export const LocalDeviceVerificationStateContext = createContext(false); diff --git a/test/components/structures/LoggedInView-test.tsx b/test/components/structures/LoggedInView-test.tsx index a8cf7ffb344..04c8b43811a 100644 --- a/test/components/structures/LoggedInView-test.tsx +++ b/test/components/structures/LoggedInView-test.tsx @@ -38,6 +38,7 @@ describe("", () => { getMediaHandler: jest.fn(), setPushRuleEnabled: jest.fn(), setPushRuleActions: jest.fn(), + getCrypto: jest.fn().mockReturnValue(undefined), }); const mediaHandler = new MediaHandler(mockClient); const mockSdkContext = new TestSdkContext(); diff --git a/test/components/structures/MatrixChat-test.tsx b/test/components/structures/MatrixChat-test.tsx index 9ee1907e692..908c7a7c04d 100644 --- a/test/components/structures/MatrixChat-test.tsx +++ b/test/components/structures/MatrixChat-test.tsx @@ -26,6 +26,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { OidcError } from "matrix-js-sdk/src/oidc/error"; import { BearerTokenResponse } from "matrix-js-sdk/src/oidc/validate"; import { defer, sleep } from "matrix-js-sdk/src/utils"; +import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; import MatrixChat from "../../../src/components/structures/MatrixChat"; import * as StorageManager from "../../../src/utils/StorageManager"; @@ -948,6 +949,9 @@ describe("", () => { const mockCrypto = { getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]), getUserDeviceInfo: jest.fn().mockResolvedValue(new Map()), + getUserVerificationStatus: jest + .fn() + .mockResolvedValue(new UserVerificationStatus(false, false, false)), }; loginClient.isCryptoEnabled.mockReturnValue(true); loginClient.getCrypto.mockReturnValue(mockCrypto as any); diff --git a/test/components/structures/MatrixClientContextProvider-test.tsx b/test/components/structures/MatrixClientContextProvider-test.tsx new file mode 100644 index 00000000000..cadd0bc1669 --- /dev/null +++ b/test/components/structures/MatrixClientContextProvider-test.tsx @@ -0,0 +1,117 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { act, render } from "@testing-library/react"; +import React, { useContext } from "react"; +import { CryptoEvent, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; + +import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; +import { MatrixClientContextProvider } from "../../../src/components/structures/MatrixClientContextProvider"; +import { LocalDeviceVerificationStateContext } from "../../../src/contexts/LocalDeviceVerificationStateContext"; +import { + flushPromises, + getMockClientWithEventEmitter, + mockClientMethodsCrypto, + mockClientMethodsUser, +} from "../../test-utils"; + +describe("MatrixClientContextProvider", () => { + it("Should expose a matrix client context", () => { + const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(), + getCrypto: () => null, + }); + + let receivedClient: MatrixClient | undefined; + function ContextReceiver() { + receivedClient = useContext(MatrixClientContext); + return <>; + } + + render( + + + , + ); + + expect(receivedClient).toBe(mockClient); + }); + + describe("Should expose a verification status context", () => { + /** The most recent verification status received by our `ContextReceiver` */ + let receivedState: boolean | undefined; + + /** The mock client for use in the tests */ + let mockClient: MatrixClient; + + function ContextReceiver() { + receivedState = useContext(LocalDeviceVerificationStateContext); + return <>; + } + + function getComponent(mockClient: MatrixClient) { + return render( + + + , + ); + } + + beforeEach(() => { + receivedState = undefined; + mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(), + ...mockClientMethodsCrypto(), + }); + }); + + it("returns false if device is unverified", async () => { + mockClient.getCrypto()!.getUserVerificationStatus = jest + .fn() + .mockResolvedValue(new UserVerificationStatus(false, false, false)); + getComponent(mockClient); + expect(receivedState).toBe(false); + }); + + it("returns true if device is verified", async () => { + mockClient.getCrypto()!.getUserVerificationStatus = jest + .fn() + .mockResolvedValue(new UserVerificationStatus(true, false, false)); + getComponent(mockClient); + await act(() => flushPromises()); + expect(receivedState).toBe(true); + }); + + it("updates when the trust status updates", async () => { + const getVerificationStatus = jest.fn().mockResolvedValue(new UserVerificationStatus(false, false, false)); + mockClient.getCrypto()!.getUserVerificationStatus = getVerificationStatus; + getComponent(mockClient); + + // starts out false + await act(() => flushPromises()); + expect(receivedState).toBe(false); + + // Now the state is updated + const verifiedStatus = new UserVerificationStatus(true, false, false); + getVerificationStatus.mockResolvedValue(verifiedStatus); + act(() => { + mockClient.emit(CryptoEvent.UserTrustStatusChanged, mockClient.getSafeUserId(), verifiedStatus); + }); + expect(receivedState).toBe(true); + }); + }); +});