Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
New context for local device verification (#12417)
Browse files Browse the repository at this point in the history
* New context for local device verification

* Fix up tests

* Use PropsWithChildren
  • Loading branch information
richvdh authored Apr 16, 2024
1 parent 3137339 commit 5ed68ef
Show file tree
Hide file tree
Showing 6 changed files with 256 additions and 3 deletions.
6 changes: 3 additions & 3 deletions src/components/structures/LoggedInView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -672,7 +672,7 @@ class LoggedInView extends React.Component<IProps, IState> {
});

return (
<MatrixClientContext.Provider value={this._matrixClient}>
<MatrixClientContextProvider client={this._matrixClient}>
<div
onPaste={this.onPaste}
onKeyDown={this.onReactKeyDown}
Expand Down Expand Up @@ -707,7 +707,7 @@ class LoggedInView extends React.Component<IProps, IState> {
<PipContainer />
<NonUrgentToastContainer />
{audioFeedArraysForCalls}
</MatrixClientContext.Provider>
</MatrixClientContextProvider>
);
}
}
Expand Down
104 changes: 104 additions & 0 deletions src/components/structures/MatrixClientContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
27 changes: 27 additions & 0 deletions src/contexts/LocalDeviceVerificationStateContext.ts
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions test/components/structures/LoggedInView-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe("<LoggedInView />", () => {
getMediaHandler: jest.fn(),
setPushRuleEnabled: jest.fn(),
setPushRuleActions: jest.fn(),
getCrypto: jest.fn().mockReturnValue(undefined),
});
const mediaHandler = new MediaHandler(mockClient);
const mockSdkContext = new TestSdkContext();
Expand Down
4 changes: 4 additions & 0 deletions test/components/structures/MatrixChat-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -948,6 +949,9 @@ describe("<MatrixChat />", () => {
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);
Expand Down
117 changes: 117 additions & 0 deletions test/components/structures/MatrixClientContextProvider-test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
});

0 comments on commit 5ed68ef

Please sign in to comment.