diff --git a/package.json b/package.json index 2b4a3fa9..40081df2 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "i18next-http-backend": "^2.0.0", "livekit-client": "^1.12.3", "lodash": "^4.17.21", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#4ce837b20e638a185f9002b2388fbaf48975ee6e", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#bf81c4bfebd52532d67d30a66e651e3658c8aaad", "matrix-widget-api": "^1.3.1", "normalize.css": "^8.0.1", "pako": "^2.0.4", diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 7bfdf900..eb3cd3b4 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -115,6 +115,10 @@ interface UrlParams { * E2EE password */ password: string | null; + /** + * Whether we the app should use per participant keys for E2EE. + */ + perParticipantE2EE: boolean; /** * Setting this flag skips the lobby and brings you in the call directly. * In the widget this can be combined with preload to pass the device settings @@ -217,6 +221,7 @@ export const getUrlParams = ( fontScale: Number.isNaN(fontScale) ? null : fontScale, analyticsID: parser.getParam("analyticsID"), allowIceFallback: parser.getFlagParam("allowIceFallback"), + perParticipantE2EE: parser.getFlagParam("perParticipantE2EE"), skipLobby: parser.getFlagParam("skipLobby"), }; }; diff --git a/src/e2ee/e2eeType.ts b/src/e2ee/e2eeType.ts new file mode 100644 index 00000000..be4a4ba9 --- /dev/null +++ b/src/e2ee/e2eeType.ts @@ -0,0 +1,21 @@ +/* +Copyright 2023 New Vector Ltd + +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. +*/ + +export enum E2eeType { + NONE = 0, + PER_PARTICIPANT = 1, + SHARED_KEY = 2, +} diff --git a/src/e2ee/matrixKeyProvider.ts b/src/e2ee/matrixKeyProvider.ts new file mode 100644 index 00000000..7fac8193 --- /dev/null +++ b/src/e2ee/matrixKeyProvider.ts @@ -0,0 +1,73 @@ +/* +Copyright 2023 New Vector Ltd + +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 { BaseKeyProvider, createKeyMaterialFromBuffer } from "livekit-client"; +import { logger } from "matrix-js-sdk/src/logger"; +import { + MatrixRTCSession, + MatrixRTCSessionEvent, +} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; + +export class MatrixKeyProvider extends BaseKeyProvider { + private rtcSession?: MatrixRTCSession; + + public constructor() { + super({ ratchetWindowSize: 0 }); + } + + public setRTCSession(rtcSession: MatrixRTCSession): void { + if (this.rtcSession) { + this.rtcSession.off( + MatrixRTCSessionEvent.EncryptionKeyChanged, + this.onEncryptionKeyChanged, + ); + } + + this.rtcSession = rtcSession; + + this.rtcSession.on( + MatrixRTCSessionEvent.EncryptionKeyChanged, + this.onEncryptionKeyChanged, + ); + + // The new session could be aware of keys of which the old session wasn't, + // so emit a key changed event. + for (const [ + participant, + encryptionKeys, + ] of this.rtcSession.getEncryptionKeys()) { + for (const [index, encryptionKey] of encryptionKeys.entries()) { + this.onEncryptionKeyChanged(encryptionKey, index, participant); + } + } + } + + private onEncryptionKeyChanged = async ( + encryptionKey: Uint8Array, + encryptionKeyIndex: number, + participantId: string, + ): Promise => { + this.onSetEncryptionKey( + await createKeyMaterialFromBuffer(encryptionKey), + participantId, + encryptionKeyIndex, + ); + + logger.debug( + `Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`, + ); + }; +} diff --git a/src/home/RegisteredView.tsx b/src/home/RegisteredView.tsx index c5ff8cfa..4da46696 100644 --- a/src/home/RegisteredView.tsx +++ b/src/home/RegisteredView.tsx @@ -40,6 +40,7 @@ import { Caption } from "../typography/Typography"; import { Form } from "../form/Form"; import { useOptInAnalytics } from "../settings/useSetting"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; +import { E2eeType } from "../e2ee/e2eeType"; interface Props { client: MatrixClient; @@ -72,7 +73,11 @@ export const RegisteredView: FC = ({ client }) => { setError(undefined); setLoading(true); - const createRoomResult = await createRoom(client, roomName, true); + const createRoomResult = await createRoom( + client, + roomName, + E2eeType.SHARED_KEY, + ); history.push( getRelativeRoomUrl( diff --git a/src/home/UnauthenticatedView.tsx b/src/home/UnauthenticatedView.tsx index 91e8b56e..732f0633 100644 --- a/src/home/UnauthenticatedView.tsx +++ b/src/home/UnauthenticatedView.tsx @@ -43,6 +43,7 @@ import { generateRandomName } from "../auth/generateRandomName"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; import { useOptInAnalytics } from "../settings/useSetting"; import { Config } from "../config/Config"; +import { E2eeType } from "../e2ee/e2eeType"; export const UnauthenticatedView: FC = () => { const { setClient } = useClient(); @@ -84,7 +85,11 @@ export const UnauthenticatedView: FC = () => { let createRoomResult; try { - createRoomResult = await createRoom(client, roomName, true); + createRoomResult = await createRoom( + client, + roomName, + E2eeType.SHARED_KEY, + ); } catch (error) { if (!setClient) { throw error; diff --git a/src/livekit/useLiveKit.ts b/src/livekit/useLiveKit.ts index 43a4703e..3e6f6397 100644 --- a/src/livekit/useLiveKit.ts +++ b/src/livekit/useLiveKit.ts @@ -26,6 +26,7 @@ import { useLiveKitRoom } from "@livekit/components-react"; import { useEffect, useMemo, useRef, useState } from "react"; import E2EEWorker from "livekit-client/e2ee-worker?worker"; import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { defaultLiveKitOptions } from "./options"; import { SFUConfig } from "./openIDSFU"; @@ -39,9 +40,12 @@ import { ECConnectionState, useECConnectionState, } from "./useECConnectionState"; +import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider"; +import { E2eeType } from "../e2ee/e2eeType"; export type E2EEConfig = { - sharedKey: string; + mode: E2eeType; + sharedKey?: string; }; interface UseLivekitResult { @@ -50,26 +54,44 @@ interface UseLivekitResult { } export function useLiveKit( + rtcSession: MatrixRTCSession, muteStates: MuteStates, sfuConfig?: SFUConfig, e2eeConfig?: E2EEConfig, ): UseLivekitResult { - const e2eeOptions = useMemo(() => { - if (!e2eeConfig?.sharedKey) return undefined; + const e2eeOptions = useMemo((): E2EEOptions | undefined => { + if (!e2eeConfig || e2eeConfig.mode === E2eeType.NONE) return undefined; - return { - keyProvider: new ExternalE2EEKeyProvider(), - worker: new E2EEWorker(), - } as E2EEOptions; + if (e2eeConfig.mode === E2eeType.PER_PARTICIPANT) { + return { + keyProvider: new MatrixKeyProvider(), + worker: new E2EEWorker(), + }; + } else if ( + e2eeConfig.mode === E2eeType.SHARED_KEY && + e2eeConfig.sharedKey + ) { + return { + keyProvider: new ExternalE2EEKeyProvider(), + worker: new E2EEWorker(), + }; + } }, [e2eeConfig]); useEffect(() => { - if (!e2eeConfig?.sharedKey || !e2eeOptions) return; + if (!e2eeConfig || !e2eeOptions) return; - (e2eeOptions.keyProvider as ExternalE2EEKeyProvider).setKey( - e2eeConfig?.sharedKey, - ); - }, [e2eeOptions, e2eeConfig?.sharedKey]); + if (e2eeConfig.mode === E2eeType.PER_PARTICIPANT) { + (e2eeOptions.keyProvider as MatrixKeyProvider).setRTCSession(rtcSession); + } else if ( + e2eeConfig.mode === E2eeType.SHARED_KEY && + e2eeConfig.sharedKey + ) { + (e2eeOptions.keyProvider as ExternalE2EEKeyProvider).setKey( + e2eeConfig.sharedKey, + ); + } + }, [e2eeOptions, e2eeConfig, rtcSession]); const initialMuteStates = useRef(muteStates); const devices = useMediaDevices(); diff --git a/src/matrix-utils.ts b/src/matrix-utils.ts index 52c55959..90fdc705 100644 --- a/src/matrix-utils.ts +++ b/src/matrix-utils.ts @@ -28,6 +28,7 @@ import { GroupCallIntent, GroupCallType, } from "matrix-js-sdk/src/webrtc/groupCall"; +import { secureRandomBase64Url } from "matrix-js-sdk/src/randomstring"; import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { Room } from "matrix-js-sdk/src/models/room"; @@ -37,6 +38,7 @@ import { loadOlm } from "./olm"; import { Config } from "./config/Config"; import { setLocalStorageItem } from "./useLocalStorage"; import { getRoomSharedKeyLocalStorageKey } from "./e2ee/sharedKeyManagement"; +import { E2eeType } from "./e2ee/e2eeType"; export const fallbackICEServerAllowed = import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true"; @@ -73,23 +75,6 @@ function waitForSync(client: MatrixClient): Promise { }); } -function secureRandomString(entropyBytes: number): string { - const key = new Uint8Array(entropyBytes); - crypto.getRandomValues(key); - // encode to base64url as this value goes into URLs - // base64url is just base64 with thw two non-alphanum characters swapped out for - // ones that can be put in a URL without encoding. Browser JS has a native impl - // for base64 encoding but only a string (there isn't one that takes a UInt8Array - // yet) so just use the built-in one and convert, replace the chars and strip the - // padding from the end (otherwise we'd need to pull in another dependency). - return btoa( - key.reduce((acc, current) => acc + String.fromCharCode(current), ""), - ) - .replace("+", "-") - .replace("/", "_") - .replace(/=*$/, ""); -} - /** * Initialises and returns a new standalone Matrix Client. * If true is passed for the 'restore' parameter, a check will be made @@ -294,10 +279,20 @@ interface CreateRoomResult { password?: string; } +/** + * Create a new room ready for calls + * + * @param client Matrix client to use + * @param name The name of the room + * @param e2ee The type of e2ee call to create. Note that we would currently never + * create a room for per-participant e2ee calls: since it's used in + * embedded mode, we use the existing room. + * @returns Object holding information about the new room + */ export async function createRoom( client: MatrixClient, name: string, - e2ee: boolean, + e2ee: E2eeType, ): Promise { logger.log(`Creating room for group call`); const createPromise = client.createRoom({ @@ -362,8 +357,8 @@ export async function createRoom( ); let password; - if (e2ee) { - password = secureRandomString(16); + if (e2ee == E2eeType.SHARED_KEY) { + password = secureRandomBase64Url(16); setLocalStorageItem( getRoomSharedKeyLocalStorageKey(result.room_id), password, diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 6466a3c6..69f466f2 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -44,6 +44,9 @@ import { useRoomAvatar } from "./useRoomAvatar"; import { useRoomName } from "./useRoomName"; import { useJoinRule } from "./useJoinRule"; import { InviteModal } from "./InviteModal"; +import { E2EEConfig } from "../livekit/useLiveKit"; +import { useUrlParams } from "../UrlParams"; +import { E2eeType } from "../e2ee/e2eeType"; declare global { interface Window { @@ -87,6 +90,7 @@ export const GroupCallView: FC = ({ const roomName = useRoomName(rtcSession.room); const roomAvatar = useRoomAvatar(rtcSession.room); const roomEncrypted = useIsRoomE2EE(rtcSession.room.roomId)!; + const { perParticipantE2EE } = useUrlParams(); const matrixInfo = useMemo((): MatrixInfo => { return { @@ -182,7 +186,7 @@ export const GroupCallView: FC = ({ ev: CustomEvent, ): Promise => { defaultDeviceSetup(ev.detail.data as unknown as JoinCallData); - enterRTCSession(rtcSession); + enterRTCSession(rtcSession, perParticipantE2EE); await Promise.all([ widget!.api.setAlwaysOnScreen(true), widget!.api.transport.reply(ev.detail, {}), @@ -195,9 +199,9 @@ export const GroupCallView: FC = ({ } else { // if we don't use preload and only skipLobby we enter the rtc session right away defaultDeviceSetup({ audioInput: null, videoInput: null }); - enterRTCSession(rtcSession); + enterRTCSession(rtcSession, perParticipantE2EE); } - }, [rtcSession, preload, skipLobby]); + }, [rtcSession, preload, skipLobby, perParticipantE2EE]); const [left, setLeft] = useState(false); const [leaveError, setLeaveError] = useState(undefined); @@ -245,16 +249,19 @@ export const GroupCallView: FC = ({ } }, [isJoined, rtcSession]); - const e2eeConfig = useMemo( - () => (e2eeSharedKey ? { sharedKey: e2eeSharedKey } : undefined), - [e2eeSharedKey], - ); + const e2eeConfig = useMemo((): E2EEConfig | undefined => { + if (perParticipantE2EE) { + return { mode: E2eeType.PER_PARTICIPANT }; + } else if (e2eeSharedKey) { + return { mode: E2eeType.SHARED_KEY, sharedKey: e2eeSharedKey }; + } + }, [perParticipantE2EE, e2eeSharedKey]); const onReconnect = useCallback(() => { setLeft(false); setLeaveError(undefined); - enterRTCSession(rtcSession); - }, [rtcSession]); + enterRTCSession(rtcSession, perParticipantE2EE); + }, [rtcSession, perParticipantE2EE]); const joinRule = useJoinRule(rtcSession.room); @@ -280,7 +287,7 @@ export const GroupCallView: FC = ({ const { t } = useTranslation(); - if (isRoomE2EE && !e2eeSharedKey) { + if (isRoomE2EE && !perParticipantE2EE && !e2eeSharedKey) { return ( = ({ client={client} matrixInfo={matrixInfo} muteStates={muteStates} - onEnter={(): void => enterRTCSession(rtcSession)} + onEnter={(): void => enterRTCSession(rtcSession, perParticipantE2EE)} confineToRoom={confineToRoom} hideHeader={hideHeader} participantCount={participantCount} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 559f01e7..ac0bbf9b 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -101,6 +101,7 @@ export interface ActiveCallProps export const ActiveCall: FC = (props) => { const sfuConfig = useOpenIDSFU(props.client, props.rtcSession); const { livekitRoom, connState } = useLiveKit( + props.rtcSession, props.muteStates, sfuConfig, props.e2eeConfig, diff --git a/src/rtcSessionHelpers.ts b/src/rtcSessionHelpers.ts index 80978098..2291e80c 100644 --- a/src/rtcSessionHelpers.ts +++ b/src/rtcSessionHelpers.ts @@ -34,7 +34,10 @@ function makeFocus(livekitAlias: string): LivekitFocus { }; } -export function enterRTCSession(rtcSession: MatrixRTCSession): void { +export function enterRTCSession( + rtcSession: MatrixRTCSession, + encryptMedia: boolean, +): void { PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId); @@ -45,7 +48,7 @@ export function enterRTCSession(rtcSession: MatrixRTCSession): void { // right now we assume everything is a room-scoped call const livekitAlias = rtcSession.room.roomId; - rtcSession.joinRoomSession([makeFocus(livekitAlias)]); + rtcSession.joinRoomSession([makeFocus(livekitAlias)], encryptMedia); } const widgetPostHangupProcedure = async ( diff --git a/src/widget.ts b/src/widget.ts index 7a8e03f8..1ca6a2e3 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -77,6 +77,8 @@ export const widget = ((): WidgetHelpers | null => { logger.info("Widget API is available"); const api = new WidgetApi(widgetId, parentOrigin); api.requestCapability(MatrixCapabilities.AlwaysOnScreen); + api.requestCapabilityToSendEvent(EventType.CallEncryptionKeysPrefix); + api.requestCapabilityToReceiveEvent(EventType.CallEncryptionKeysPrefix); // Set up the lazy action emitter, but only for select actions that we // intend for the app to handle diff --git a/yarn.lock b/yarn.lock index 81bdeb1c..bb1152c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1997,10 +1997,10 @@ clsx "^2.0.0" usehooks-ts "^2.9.1" -"@matrix-org/matrix-sdk-crypto-wasm@^1.2.3-alpha.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-1.3.0.tgz#f098c72701801334eeb7049ca7074fe6eb3686d6" - integrity sha512-vQ5PVppKu1PY7xy7QDw+RJLYLGFKhJyxLqjXHr0uEUJwfvz2IH2njTLXzrz77dOo9qacxJ9/YNOTe0Hl+98N0A== +"@matrix-org/matrix-sdk-crypto-wasm@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-2.2.0.tgz#7c60afe01915281a6b71502821bc8e01afbfa70d" + integrity sha512-txmvaTiZpVV0/kWCRcE7tZvRESCEc1ynLJDVh9OUsFlaXfl13c7qdD3E6IJEJ8YiPMIn+PHogdfBZsO84reaMg== "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz": version "3.2.14" @@ -7066,12 +7066,12 @@ matrix-events-sdk@0.0.1: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== -"matrix-js-sdk@github:matrix-org/matrix-js-sdk#4ce837b20e638a185f9002b2388fbaf48975ee6e": - version "29.0.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4ce837b20e638a185f9002b2388fbaf48975ee6e" +"matrix-js-sdk@github:matrix-org/matrix-js-sdk#bf81c4bfebd52532d67d30a66e651e3658c8aaad": + version "29.1.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/bf81c4bfebd52532d67d30a66e651e3658c8aaad" dependencies: "@babel/runtime" "^7.12.5" - "@matrix-org/matrix-sdk-crypto-wasm" "^1.2.3-alpha.0" + "@matrix-org/matrix-sdk-crypto-wasm" "^2.2.0" another-json "^0.2.0" bs58 "^5.0.0" content-type "^1.0.4"