-
-
Notifications
You must be signed in to change notification settings - Fork 648
MatrixRTC: Refactor | Introduce a new Encryption manager (used with experimental to device transport) #4799
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
0f4e370
refactor: New encryption manager BasicEncryptionManager for todevice
BillCarsonFr b19c7a6
fix: ToDevice transport not setting the sent_ts
BillCarsonFr 6411597
test: BasicEncryptionManager add statistics tests
BillCarsonFr 7e0cf4d
code review
BillCarsonFr ecf3f82
Merge branch 'develop' into valere/rtc/simple_encryption_manager
BillCarsonFr c1b9e0f
feat: Encryption manager just reshare on new joiner
BillCarsonFr 8ad79ef
refactor: Rename BasicEncryptionManger to RTCEncryptionManager
BillCarsonFr ec4c466
fixup: RTC experimental todevice should use new encryption mgr
BillCarsonFr 80bd66d
fixup: use proper logger hierarchy
BillCarsonFr d79fc58
fixup: RTC rollout first key asap even if no members to send to
BillCarsonFr 8e9af36
fixup: RTC add test for first key use
BillCarsonFr 9b06920
Merge branch 'develop' into valere/rtc/simple_encryption_manager
BillCarsonFr a9413f9
fixup! emitting outbound key before anyone registered
BillCarsonFr be3c359
fix: quick patch for transport switch, need test
BillCarsonFr 07af3d9
test: RTC encryption manager, add test for transport switch
BillCarsonFr 5e7043f
Merge branch 'develop' into valere/rtc/simple_encryption_manager
BillCarsonFr cf05a8f
post rebase fix
BillCarsonFr 64cbec1
Merge branch 'develop' into valere/rtc/simple_encryption_manager
BillCarsonFr fdf81a1
Merge branch 'develop' into valere/rtc/simple_encryption_manager
BillCarsonFr 58fb9f1
Remove bad corepack commit
BillCarsonFr 9b95aa7
review: cleaning, renaming
BillCarsonFr 2aa07b6
Merge remote-tracking branch 'origin/valere/rtc/simple_encryption_man…
BillCarsonFr 85dd660
review: cleaning and renaming
BillCarsonFr 927de6d
stop using root logger in favor of a parent logger
BillCarsonFr a06bce7
post merge fix broken test
BillCarsonFr 1341212
remove corepack again
BillCarsonFr b7b7619
Merge branch 'develop' into valere/rtc/simple_encryption_manager
BillCarsonFr 47c481e
fix reverted changes after a merge
BillCarsonFr 9a943c7
review: Properly deprecate getEncryptionKeys
BillCarsonFr a7ee938
review: rename ensureMediaKeyDistribution to ensureKeyDistribution
BillCarsonFr dc3fb98
review: use OutdatedKeyFilter instead of KeyBuffer
BillCarsonFr f9804d2
Merge branch 'develop' into valere/rtc/simple_encryption_manager
BillCarsonFr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| /* | ||
| Copyright 2025 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 { KeyBuffer } from "../../../src/matrixrtc/utils.ts"; | ||
| import { type InboundEncryptionSession } from "../../../src/matrixrtc"; | ||
|
|
||
| describe("KeyBuffer Test", () => { | ||
| it("Should buffer and disambiguate keys by timestamp", () => { | ||
| jest.useFakeTimers(); | ||
|
|
||
| const buffer = new KeyBuffer(1000); | ||
|
|
||
| const aKey = fakeInboundSessionWithTimestamp(1000); | ||
| const olderKey = fakeInboundSessionWithTimestamp(300); | ||
| // Simulate receiving out of order keys | ||
|
|
||
| const init = buffer.disambiguate(aKey.participantId, aKey); | ||
| expect(init).toEqual(aKey); | ||
| // Some time pass | ||
| jest.advanceTimersByTime(600); | ||
| // Then we receive the most recent key out of order | ||
|
|
||
| const key = buffer.disambiguate(aKey.participantId, olderKey); | ||
| // this key is older and should be ignored even if received after | ||
| expect(key).toBe(null); | ||
| }); | ||
|
|
||
| it("Should clear buffer after ttl", () => { | ||
| jest.useFakeTimers(); | ||
|
|
||
| const buffer = new KeyBuffer(1000); | ||
|
|
||
| const aKey = fakeInboundSessionWithTimestamp(1000); | ||
| const olderKey = fakeInboundSessionWithTimestamp(300); | ||
| // Simulate receiving out of order keys | ||
|
|
||
| const init = buffer.disambiguate(aKey.participantId, aKey); | ||
| expect(init).toEqual(aKey); | ||
|
|
||
| // Similar to previous test but there is too much delay | ||
| // We don't want to keep key material for too long | ||
| jest.advanceTimersByTime(1200); | ||
|
|
||
| const key = buffer.disambiguate(aKey.participantId, olderKey); | ||
| // The buffer is cleared so should return this key | ||
| expect(key).toBe(olderKey); | ||
| }); | ||
|
|
||
| function fakeInboundSessionWithTimestamp(ts: number): InboundEncryptionSession { | ||
| return { | ||
| keyId: 0, | ||
| creationTS: ts, | ||
| participantId: "@alice:localhost|ABCDE", | ||
| key: new Uint8Array(16), | ||
| }; | ||
| } | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| /* | ||
| Copyright 2025 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 { type IEncryptionManager } from "./EncryptionManager.ts"; | ||
| import { type EncryptionConfig } from "./MatrixRTCSession.ts"; | ||
| import { type CallMembership } from "./CallMembership.ts"; | ||
| import { decodeBase64, encodeBase64 } from "../base64.ts"; | ||
| import { type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts"; | ||
| import { type Logger, logger } from "../logger.ts"; | ||
| import { defer } from "../utils.ts"; | ||
| import { type ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; | ||
| import { type InboundEncryptionSession, type ParticipantId, type Statistics } from "./types.ts"; | ||
| import { KeyBuffer } from "./utils.ts"; | ||
|
|
||
| type DeviceInfo = { | ||
| userId: string; | ||
| deviceId: string; | ||
| }; | ||
|
|
||
| type OutboundEncryptionSession = { | ||
| key: Uint8Array; | ||
| creationTS: number; | ||
| sharedWith: Array<DeviceInfo>; | ||
| // This is an index acting as the id of the key | ||
| keyId: number; | ||
| }; | ||
|
|
||
| /** | ||
| * A simple encryption manager. | ||
| * This manager is basic becasue it will rotate the keys for any membership change. | ||
| * There is no ratchetting, or time based rotation. | ||
| * It works with to-device transport. | ||
| */ | ||
| export class BasicEncryptionManager implements IEncryptionManager { | ||
| // The current per-sender media key for this device | ||
| private outboundSession: OutboundEncryptionSession | null = null; | ||
|
|
||
| /** | ||
| * Ensures that there is only one distribute operation at a time for that call. | ||
| */ | ||
| private currentKeyDistributionPromise: Promise<void> | null = null; | ||
|
|
||
| /** | ||
| * There is a possibility that keys arrive in wrong order. | ||
| * For example after a quick join/leave/join, there will be 2 keys of index 0 distributed and | ||
| * it they are received in wrong order the stream won't be decryptable. | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| * For that reason we keep a small buffer of keys for a limited time to disambiguate. | ||
| * @private | ||
| */ | ||
| private keyBuffer = new KeyBuffer(1000 /** 1 second */); | ||
|
|
||
| private logger: Logger; | ||
|
|
||
| private needToRotateAgain = false; | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| public constructor( | ||
| private userId: string, | ||
| private deviceId: string, | ||
| private getMemberships: () => CallMembership[], | ||
| private transport: ToDeviceKeyTransport, | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| private statistics: Statistics, | ||
| private onEncryptionKeysChanged: ( | ||
| keyBin: Uint8Array<ArrayBufferLike>, | ||
| encryptionKeyIndex: number, | ||
| participantId: ParticipantId, | ||
| ) => void, | ||
| ) { | ||
| this.logger = logger.getChild("BasicEncryptionManager"); | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> { | ||
| // This is deprecated should be ignored. Only use by tests? | ||
| return new Map(); | ||
| } | ||
|
||
|
|
||
| public join(joinConfig: EncryptionConfig | undefined): void { | ||
| this.logger.info(`Joining room`); | ||
| this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived); | ||
| this.transport.start(); | ||
|
|
||
| this.ensureMediaKey(); | ||
| } | ||
|
|
||
| /** | ||
| * Will ensure that a new key is distributed and used to encrypt our media. | ||
| * If this function is called repeatidly, the calls will be buffered to a single key rotation. | ||
| */ | ||
| private ensureMediaKey(): void { | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| if (this.currentKeyDistributionPromise == null) { | ||
| this.logger.debug(`No active rollout, start a new one`); | ||
| // start a rollout | ||
| this.currentKeyDistributionPromise = this.rolloutOutboundKey().then(() => { | ||
| this.logger.debug(`Rollout completed`); | ||
| this.currentKeyDistributionPromise = null; | ||
| if (this.needToRotateAgain) { | ||
| this.logger.debug(`New Rollout needed`); | ||
| // rollout a new one | ||
| this.ensureMediaKey(); | ||
| } | ||
| }); | ||
| } else { | ||
| // There is a rollout in progress, but membership has changed and a new rollout is needed. | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| // Remember that a new rotation in needed after the current one. | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| this.logger.debug(`Rollout in progress, a new rollout will be started after the current one`); | ||
| this.needToRotateAgain = true; | ||
| } | ||
| } | ||
|
|
||
| public leave(): void { | ||
| this.keyBuffer.clear(); | ||
| this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived); | ||
| this.transport.stop(); | ||
| } | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => { | ||
| this.logger.debug(`Received key over transport ${userId}:${deviceId} at index ${index}`); | ||
|
|
||
| // We have a new key notify the video layer of this new key so that it can decrypt the frames properly. | ||
| // We also store a copy of the key in the key store as we might need to re-emit them to the decoding layer. | ||
| const participantId = getParticipantId(userId, deviceId); | ||
| const keyBin = decodeBase64(keyBase64Encoded); | ||
| const newKey: InboundEncryptionSession = { | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| key: keyBin, | ||
| participantId, | ||
| keyId: index, | ||
| creationTS: timestamp, | ||
| }; | ||
|
|
||
| const validKey = this.keyBuffer.disambiguate(participantId, newKey); | ||
| if (validKey) { | ||
| this.onEncryptionKeysChanged(validKey.key, index, validKey.participantId); | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| this.statistics.counters.roomEventEncryptionKeysReceived += 1; | ||
| } else { | ||
| this.logger.info(`Received an out of order key for ${userId}:${deviceId}, dropping it`); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Called when the membership of the call changes. | ||
| * This encryption manager is very basic, it will rotate the key everytime this is called. | ||
| * @param oldMemberships | ||
| */ | ||
| public onMembershipsUpdate(oldMemberships: CallMembership[]): void { | ||
| this.logger.trace(`onMembershipsUpdate`); | ||
|
|
||
| // This encryption manager is very basic, it will rotate the key for any membership change | ||
| // Request rotation of the key | ||
| this.ensureMediaKey(); | ||
| } | ||
|
|
||
| private async rolloutOutboundKey(): Promise<void> { | ||
| const hasExistingKey = this.outboundSession != null; | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| // Create a new key | ||
| const newOutboundKey: OutboundEncryptionSession = { | ||
| key: this.generateRandomKey(), | ||
| creationTS: Date.now(), | ||
| sharedWith: [], | ||
| keyId: this.nextKeyId(), | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }; | ||
|
|
||
| this.logger.info(`creating new outbound key index:${newOutboundKey.keyId}`); | ||
| // Set this new key has the current one | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| this.outboundSession = newOutboundKey; | ||
| this.needToRotateAgain = false; | ||
| const toShareWith = this.getMemberships(); | ||
|
|
||
| try { | ||
| this.logger.trace(`Sending key...`); | ||
| await this.transport.sendKey(encodeBase64(newOutboundKey.key), newOutboundKey.keyId, toShareWith); | ||
| this.statistics.counters.roomEventEncryptionKeysSent += 1; | ||
| newOutboundKey.sharedWith = toShareWith.map((ms) => { | ||
| return { | ||
| userId: ms.sender ?? "", | ||
| deviceId: ms.deviceId ?? "", | ||
| }; | ||
| }); | ||
| this.logger.trace( | ||
| `key index:${newOutboundKey.keyId} sent to ${newOutboundKey.sharedWith.map((m) => `${m.userId}:${m.deviceId}`).join(",")}`, | ||
| ); | ||
| if (!hasExistingKey) { | ||
| this.logger.trace(`Rollout immediately`); | ||
| // rollout immediately | ||
| this.onEncryptionKeysChanged( | ||
| newOutboundKey.key, | ||
| newOutboundKey.keyId, | ||
| getParticipantId(this.userId, this.deviceId), | ||
| ); | ||
| } else { | ||
| // Delay a bit using this key | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| const rolledOut = defer<void>(); | ||
| this.logger.trace(`Delay Rollout...`); | ||
| setTimeout(() => { | ||
| this.logger.trace(`...Delayed rollout of index:${newOutboundKey.keyId} `); | ||
| // Start encrypting with that key now that there was time to distibute it | ||
| this.onEncryptionKeysChanged( | ||
| newOutboundKey.key, | ||
| newOutboundKey.keyId, | ||
| getParticipantId(this.userId, this.deviceId), | ||
| ); | ||
| rolledOut.resolve(); | ||
| }, 1000); | ||
| return rolledOut.promise; | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| } catch (err) { | ||
| this.logger.error(`Failed to rollout key`, err); | ||
| } | ||
| } | ||
|
|
||
| private nextKeyId(): number { | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| if (this.outboundSession) { | ||
| return (this.outboundSession!.keyId + 1) % 256; | ||
| } | ||
| return 0; | ||
| } | ||
|
|
||
| private generateRandomKey(): Uint8Array { | ||
| const key = new Uint8Array(16); | ||
| globalThis.crypto.getRandomValues(key); | ||
| return key; | ||
| } | ||
| } | ||
|
|
||
| const getParticipantId = (userId: string, deviceId: string): ParticipantId => `${userId}:${deviceId}`; | ||
BillCarsonFr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.