diff --git a/.env b/.env.example similarity index 100% rename from .env rename to .env.example diff --git a/.gitignore b/.gitignore index 6a87b455..9bafa524 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules .DS_Store +.env dist dist-ssr *.local diff --git a/README.md b/README.md index 75c9a5e6..076cb2c5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ git clone https://github.com/vector-im/element-call.git cd element-call yarn yarn link matrix-js-sdk +cp .env.example .env yarn dev ``` diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index a37aaa10..85f07cb1 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -117,12 +117,15 @@ export const ClientProvider: FC = ({ children }) => { session; logger.log("Using a standalone client"); - const client = await initClient({ - baseUrl: defaultHomeserver, - accessToken: access_token, - userId: user_id, - deviceId: device_id, - }); + const client = await initClient( + { + baseUrl: defaultHomeserver, + accessToken: access_token, + userId: user_id, + deviceId: device_id, + }, + true + ); /* eslint-enable camelcase */ return { client, isPasswordlessUser: passwordlessUser }; diff --git a/src/auth/generateRandomName.ts b/src/auth/generateRandomName.ts index b58c1114..2232d356 100644 --- a/src/auth/generateRandomName.ts +++ b/src/auth/generateRandomName.ts @@ -19,7 +19,6 @@ import { adjectives, colors, animals, - Config, } from "unique-names-generator"; const elements = [ @@ -143,12 +142,11 @@ const elements = [ "oganesson", ]; -export function generateRandomName(config: Config): string { +export function generateRandomName(): string { return uniqueNamesGenerator({ dictionaries: [colors, adjectives, animals, elements], style: "lowerCase", length: 3, separator: "-", - ...config, }); } diff --git a/src/auth/useInteractiveLogin.ts b/src/auth/useInteractiveLogin.ts index 6379c67c..9ffa7ba6 100644 --- a/src/auth/useInteractiveLogin.ts +++ b/src/auth/useInteractiveLogin.ts @@ -57,12 +57,15 @@ export const useInteractiveLogin = () => passwordlessUser: false, }; - const client = await initClient({ - baseUrl: defaultHomeserver, - accessToken: access_token, - userId: user_id, - deviceId: device_id, - }); + const client = await initClient( + { + baseUrl: defaultHomeserver, + accessToken: access_token, + userId: user_id, + deviceId: device_id, + }, + false + ); /* eslint-enable camelcase */ return [client, session]; diff --git a/src/auth/useInteractiveRegistration.ts b/src/auth/useInteractiveRegistration.ts index af611f9d..8b8647a0 100644 --- a/src/auth/useInteractiveRegistration.ts +++ b/src/auth/useInteractiveRegistration.ts @@ -90,12 +90,15 @@ export const useInteractiveRegistration = (): [ const { user_id, access_token, device_id } = (await interactiveAuth.attemptAuth()) as any; - const client = await initClient({ - baseUrl: defaultHomeserver, - accessToken: access_token, - userId: user_id, - deviceId: device_id, - }); + const client = await initClient( + { + baseUrl: defaultHomeserver, + accessToken: access_token, + userId: user_id, + deviceId: device_id, + }, + false + ); await client.setDisplayName(displayName); diff --git a/src/auth/useRegisterPasswordlessUser.ts b/src/auth/useRegisterPasswordlessUser.ts new file mode 100644 index 00000000..73189471 --- /dev/null +++ b/src/auth/useRegisterPasswordlessUser.ts @@ -0,0 +1,59 @@ +/* +Copyright 2022 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 { useCallback } from "react"; +import { randomString } from "matrix-js-sdk/src/randomstring"; + +import { useClient } from "../ClientContext"; +import { useInteractiveRegistration } from "../auth/useInteractiveRegistration"; +import { generateRandomName } from "../auth/generateRandomName"; +import { useRecaptcha } from "../auth/useRecaptcha"; + +export interface UseRegisterPasswordlessUserType { + privacyPolicyUrl: string; + registerPasswordlessUser: (displayName: string) => Promise; + recaptchaId: string; +} + +export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType { + const { setClient } = useClient(); + const [privacyPolicyUrl, recaptchaKey, register] = + useInteractiveRegistration(); + const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); + + const registerPasswordlessUser = useCallback( + async (displayName: string) => { + try { + const recaptchaResponse = await execute(); + const userName = generateRandomName(); + const [client, session] = await register( + userName, + randomString(16), + displayName, + recaptchaResponse, + true + ); + setClient(client, session); + } catch (e) { + reset(); + throw e; + } + }, + [execute, reset, register, setClient] + ); + + return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId }; +} diff --git a/src/matrix-utils.ts b/src/matrix-utils.ts index 6e51605c..63547cf1 100644 --- a/src/matrix-utils.ts +++ b/src/matrix-utils.ts @@ -26,6 +26,19 @@ export const defaultHomeserver = export const defaultHomeserverHost = new URL(defaultHomeserver).host; +export class CryptoStoreIntegrityError extends Error { + constructor() { + super("Crypto store data was expected, but none was found"); + } +} + +const SYNC_STORE_NAME = "element-call-sync"; +// Note that the crypto store name has changed from previous versions +// deliberately in order to force a logout for all users due to +// https://github.com/vector-im/element-call/issues/464 +// (It's a good opportunity to make the database names consistent.) +const CRYPTO_STORE_NAME = "element-call-crypto"; + function waitForSync(client: MatrixClient) { return new Promise((resolve, reject) => { const onSync = ( @@ -66,6 +79,12 @@ const SEND_RECV_TO_DEVICE = [ "org.matrix.call_duplicate_session", ]; +/** + * Initialises and returns a new widget-API-based Matrix Client. + * @param widgetId The ID of the widget that the app is running inside. + * @param parentUrl The URL of the parent client. + * @returns The MatrixClient instance + */ export async function initMatroskaClient( widgetId: string, parentUrl: string, ): Promise { @@ -102,8 +121,18 @@ export async function initMatroskaClient( return client; } +/** + * Initialises and returns a new standalone Matrix Client. + * If true is passed for the 'restore' parameter, a check will be made + * to ensure that corresponding crypto data is stored and recovered. + * If the check fails, CryptoStoreIntegrityError will be thrown. + * @param clientOptions Object of options passed through to the client + * @param restore Whether the session is being restored from storage + * @returns The MatrixClient instance + */ export async function initClient( - clientOptions: ICreateClientOpts + clientOptions: ICreateClientOpts, + restore: boolean ): Promise { // TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10 window.OLM_OPTIONS = {}; @@ -121,17 +150,45 @@ export async function initClient( storeOpts.store = new IndexedDBStore({ indexedDB: window.indexedDB, localStorage, - dbName: "element-call-sync", + dbName: SYNC_STORE_NAME, workerFactory: () => new IndexedDBWorker(), }); } else if (localStorage) { storeOpts.store = new MemoryStore({ localStorage }); } + // Check whether we have crypto data store. If we are restoring a session + // from storage then we will have started the crypto store and therefore + // have generated keys for that device, so if we can't recover those keys, + // we must not continue or we'll generate new keys and anyone who saw our + // previous keys will not accept our new key. + // It's worth mentioning here that if support for indexeddb or localstorage + // appears or disappears between sessions (it happens) then the failure mode + // here will be that we'll try a different store, not find crypto data and + // fail to restore the session. An alternative would be to continue using + // whatever we were using before, but that could be confusing since you could + // enable indexeddb and but the app would still not be using it. + if (restore) { + if (indexedDB) { + const cryptoStoreExists = await IndexedDBCryptoStore.exists( + indexedDB, + CRYPTO_STORE_NAME + ); + if (!cryptoStoreExists) throw new CryptoStoreIntegrityError(); + } else if (localStorage) { + if (!LocalStorageCryptoStore.exists(localStorage)) + throw new CryptoStoreIntegrityError(); + } else { + // if we get here then we're using the memory store, which cannot + // possibly have remembered a session, so it's an error. + throw new CryptoStoreIntegrityError(); + } + } + if (indexedDB) { storeOpts.cryptoStore = new IndexedDBCryptoStore( indexedDB, - "matrix-js-sdk:crypto" + CRYPTO_STORE_NAME ); } else if (localStorage) { storeOpts.cryptoStore = new LocalStorageCryptoStore(localStorage); diff --git a/src/room/RoomAuthView.jsx b/src/room/RoomAuthView.jsx index 4bf2303a..05613146 100644 --- a/src/room/RoomAuthView.jsx +++ b/src/room/RoomAuthView.jsx @@ -16,26 +16,21 @@ limitations under the License. import React, { useCallback, useState } from "react"; import styles from "./RoomAuthView.module.css"; -import { useClient } from "../ClientContext"; import { Button } from "../button"; import { Body, Caption, Link, Headline } from "../typography/Typography"; import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; import { useLocation } from "react-router-dom"; -import { useRecaptcha } from "../auth/useRecaptcha"; import { FieldRow, InputField, ErrorMessage } from "../input/Input"; -import { randomString } from "matrix-js-sdk/src/randomstring"; -import { useInteractiveRegistration } from "../auth/useInteractiveRegistration"; import { Form } from "../form/Form"; import { UserMenuContainer } from "../UserMenuContainer"; -import { generateRandomName } from "../auth/generateRandomName"; +import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; export function RoomAuthView() { - const { setClient } = useClient(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); - const [privacyPolicyUrl, recaptchaKey, register] = - useInteractiveRegistration(); - const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); + + const { registerPasswordlessUser, recaptchaId, privacyPolicyUrl } = + useRegisterPasswordlessUser(); const onSubmit = useCallback( (e) => { @@ -43,29 +38,13 @@ export function RoomAuthView() { const data = new FormData(e.target); const displayName = data.get("displayName"); - async function submit() { - setError(undefined); - setLoading(true); - const recaptchaResponse = await execute(); - const userName = generateRandomName(); - const [client, session] = await register( - userName, - randomString(16), - displayName, - recaptchaResponse, - true - ); - setClient(client, session); - } - - submit().catch((error) => { - console.error(error); + registerPasswordlessUser(displayName).catch((error) => { + console.error("Failed to register passwordless user", e); setLoading(false); setError(error); - reset(); }); }, - [register, reset, execute] + [registerPasswordlessUser] ); const location = useLocation(); diff --git a/src/room/RoomPage.jsx b/src/room/RoomPage.jsx index 1fe7eea6..72f6bf44 100644 --- a/src/room/RoomPage.jsx +++ b/src/room/RoomPage.jsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useMemo } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useLocation, useParams } from "react-router-dom"; import { useClient } from "../ClientContext"; import { ErrorView, LoadingView } from "../FullScreenView"; @@ -22,6 +22,7 @@ import { RoomAuthView } from "./RoomAuthView"; import { GroupCallLoader } from "./GroupCallLoader"; import { GroupCallView } from "./GroupCallView"; import { MediaHandlerProvider } from "../settings/useMediaHandler"; +import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; export function RoomPage() { const { loading, isAuthenticated, error, client, isPasswordlessUser } = @@ -29,17 +30,37 @@ export function RoomPage() { const { roomId: maybeRoomId } = useParams(); const { hash, search } = useLocation(); - const [viaServers, isEmbedded, isPtt] = useMemo(() => { + const [viaServers, isEmbedded, isPtt, displayName] = useMemo(() => { const params = new URLSearchParams(search); return [ params.getAll("via"), params.has("embed"), params.get("ptt") === "true", + params.get("displayName"), ]; }, [search]); const roomId = (maybeRoomId || hash || "").toLowerCase(); + const { registerPasswordlessUser, recaptchaId } = + useRegisterPasswordlessUser(); + const [isRegistering, setIsRegistering] = useState(false); - if (loading) { + useEffect(() => { + // If we're not already authed and we've been given a display name as + // a URL param, automatically register a passwordless user + if (!isAuthenticated && displayName) { + setIsRegistering(true); + registerPasswordlessUser(displayName).finally(() => { + setIsRegistering(false); + }); + } + }, [ + isAuthenticated, + displayName, + setIsRegistering, + registerPasswordlessUser, + ]); + + if (loading || isRegistering) { return ; }