New context for local device verification (#12417)

* New context for local device verification

* Fix up tests

* Use PropsWithChildren
This commit is contained in:
Richard van der Hoff 2024-04-16 10:43:27 +01:00 committed by GitHub
parent 31373399f9
commit 5ed68efa6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 256 additions and 3 deletions

View File

@ -38,7 +38,6 @@ import SettingsStore from "../../settings/SettingsStore";
import { SettingLevel } from "../../settings/SettingLevel"; import { SettingLevel } from "../../settings/SettingLevel";
import ResizeHandle from "../views/elements/ResizeHandle"; import ResizeHandle from "../views/elements/ResizeHandle";
import { CollapseDistributor, Resizer } from "../../resizer"; import { CollapseDistributor, Resizer } from "../../resizer";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import ResizeNotifier from "../../utils/ResizeNotifier"; import ResizeNotifier from "../../utils/ResizeNotifier";
import PlatformPeg from "../../PlatformPeg"; import PlatformPeg from "../../PlatformPeg";
import { DefaultTagID } from "../../stores/room-list/models"; import { DefaultTagID } from "../../stores/room-list/models";
@ -75,6 +74,7 @@ import { UserOnboardingPage } from "../views/user-onboarding/UserOnboardingPage"
import { PipContainer } from "./PipContainer"; import { PipContainer } from "./PipContainer";
import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushRules"; import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushRules";
import { ConfigOptions } from "../../SdkConfig"; import { ConfigOptions } from "../../SdkConfig";
import { MatrixClientContextProvider } from "./MatrixClientContextProvider";
// We need to fetch each pinned message individually (if we don't already have it) // 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. // so each pinned message may trigger a request. Limit the number per room for sanity.
@ -672,7 +672,7 @@ class LoggedInView extends React.Component<IProps, IState> {
}); });
return ( return (
<MatrixClientContext.Provider value={this._matrixClient}> <MatrixClientContextProvider client={this._matrixClient}>
<div <div
onPaste={this.onPaste} onPaste={this.onPaste}
onKeyDown={this.onReactKeyDown} onKeyDown={this.onReactKeyDown}
@ -707,7 +707,7 @@ class LoggedInView extends React.Component<IProps, IState> {
<PipContainer /> <PipContainer />
<NonUrgentToastContainer /> <NonUrgentToastContainer />
{audioFeedArraysForCalls} {audioFeedArraysForCalls}
</MatrixClientContext.Provider> </MatrixClientContextProvider>
); );
} }
} }

View File

@ -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<Props>): React.JSX.Element {
const verificationState = useLocalVerificationState(props.client);
return (
<MatrixClientContext.Provider value={props.client}>
<LocalDeviceVerificationStateContext.Provider value={verificationState}>
{props.children}
</LocalDeviceVerificationStateContext.Provider>
</MatrixClientContext.Provider>
);
}

View File

@ -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);

View File

@ -38,6 +38,7 @@ describe("<LoggedInView />", () => {
getMediaHandler: jest.fn(), getMediaHandler: jest.fn(),
setPushRuleEnabled: jest.fn(), setPushRuleEnabled: jest.fn(),
setPushRuleActions: jest.fn(), setPushRuleActions: jest.fn(),
getCrypto: jest.fn().mockReturnValue(undefined),
}); });
const mediaHandler = new MediaHandler(mockClient); const mediaHandler = new MediaHandler(mockClient);
const mockSdkContext = new TestSdkContext(); const mockSdkContext = new TestSdkContext();

View File

@ -26,6 +26,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { OidcError } from "matrix-js-sdk/src/oidc/error"; import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { BearerTokenResponse } from "matrix-js-sdk/src/oidc/validate"; import { BearerTokenResponse } from "matrix-js-sdk/src/oidc/validate";
import { defer, sleep } from "matrix-js-sdk/src/utils"; 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 MatrixChat from "../../../src/components/structures/MatrixChat";
import * as StorageManager from "../../../src/utils/StorageManager"; import * as StorageManager from "../../../src/utils/StorageManager";
@ -948,6 +949,9 @@ describe("<MatrixChat />", () => {
const mockCrypto = { const mockCrypto = {
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]), getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
getUserDeviceInfo: jest.fn().mockResolvedValue(new Map()), getUserDeviceInfo: jest.fn().mockResolvedValue(new Map()),
getUserVerificationStatus: jest
.fn()
.mockResolvedValue(new UserVerificationStatus(false, false, false)),
}; };
loginClient.isCryptoEnabled.mockReturnValue(true); loginClient.isCryptoEnabled.mockReturnValue(true);
loginClient.getCrypto.mockReturnValue(mockCrypto as any); loginClient.getCrypto.mockReturnValue(mockCrypto as any);

View File

@ -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(
<MatrixClientContextProvider client={mockClient}>
<ContextReceiver />
</MatrixClientContextProvider>,
);
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(
<MatrixClientContextProvider client={mockClient}>
<ContextReceiver />
</MatrixClientContextProvider>,
);
}
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);
});
});
});