Merge pull request #1772 from vector-im/dbkr/ppe2ee

Support key-per-participant e2ee
This commit is contained in:
David Baker
2023-10-31 20:08:05 +00:00
committed by GitHub
13 changed files with 195 additions and 56 deletions

View File

@@ -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",

View File

@@ -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"),
};
};

21
src/e2ee/e2eeType.ts Normal file
View File

@@ -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,
}

View File

@@ -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<void> => {
this.onSetEncryptionKey(
await createKeyMaterialFromBuffer(encryptionKey),
participantId,
encryptionKeyIndex,
);
logger.debug(
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
);
};
}

View File

@@ -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<Props> = ({ client }) => {
setError(undefined);
setLoading(true);
const createRoomResult = await createRoom(client, roomName, true);
const createRoomResult = await createRoom(
client,
roomName,
E2eeType.SHARED_KEY,
);
history.push(
getRelativeRoomUrl(

View File

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

View File

@@ -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>(muteStates);
const devices = useMediaDevices();

View File

@@ -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<void> {
});
}
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<CreateRoomResult> {
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,

View File

@@ -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<Props> = ({
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<Props> = ({
ev: CustomEvent<IWidgetApiRequest>,
): Promise<void> => {
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<Props> = ({
} 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<Error | undefined>(undefined);
@@ -245,16 +249,19 @@ export const GroupCallView: FC<Props> = ({
}
}, [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<Props> = ({
const { t } = useTranslation();
if (isRoomE2EE && !e2eeSharedKey) {
if (isRoomE2EE && !perParticipantE2EE && !e2eeSharedKey) {
return (
<ErrorView
error={
@@ -370,7 +377,7 @@ export const GroupCallView: FC<Props> = ({
client={client}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={(): void => enterRTCSession(rtcSession)}
onEnter={(): void => enterRTCSession(rtcSession, perParticipantE2EE)}
confineToRoom={confineToRoom}
hideHeader={hideHeader}
participantCount={participantCount}

View File

@@ -101,6 +101,7 @@ export interface ActiveCallProps
export const ActiveCall: FC<ActiveCallProps> = (props) => {
const sfuConfig = useOpenIDSFU(props.client, props.rtcSession);
const { livekitRoom, connState } = useLiveKit(
props.rtcSession,
props.muteStates,
sfuConfig,
props.e2eeConfig,

View File

@@ -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 (

View File

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

View File

@@ -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"