matrix-js-sdk 42.3.0 → 42.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/lib/@types/json.d.ts.map +1 -1
- package/lib/@types/json.js.map +1 -1
- package/lib/client.d.ts +74 -5
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +83 -7
- package/lib/client.js.map +1 -1
- package/lib/common-crypto/CryptoBackend.d.ts +42 -23
- package/lib/common-crypto/CryptoBackend.d.ts.map +1 -1
- package/lib/common-crypto/CryptoBackend.js +7 -0
- package/lib/common-crypto/CryptoBackend.js.map +1 -1
- package/lib/embedded.d.ts +35 -1
- package/lib/embedded.d.ts.map +1 -1
- package/lib/embedded.js +49 -1
- package/lib/embedded.js.map +1 -1
- package/lib/http-api/fetch.d.ts +0 -1
- package/lib/http-api/fetch.d.ts.map +1 -1
- package/lib/http-api/fetch.js +3 -22
- package/lib/http-api/fetch.js.map +1 -1
- package/lib/http-api/logging.d.ts +10 -0
- package/lib/http-api/logging.d.ts.map +1 -0
- package/lib/http-api/logging.js +46 -0
- package/lib/http-api/logging.js.map +1 -0
- package/lib/matrixrtc/EncryptionManager.d.ts +5 -0
- package/lib/matrixrtc/EncryptionManager.d.ts.map +1 -1
- package/lib/matrixrtc/EncryptionManager.js.map +1 -1
- package/lib/matrixrtc/LivekitTransport.d.ts +82 -0
- package/lib/matrixrtc/LivekitTransport.d.ts.map +1 -1
- package/lib/matrixrtc/LivekitTransport.js +25 -0
- package/lib/matrixrtc/LivekitTransport.js.map +1 -1
- package/lib/matrixrtc/MatrixRTCSession.d.ts +41 -2
- package/lib/matrixrtc/MatrixRTCSession.d.ts.map +1 -1
- package/lib/matrixrtc/MatrixRTCSession.js +59 -1
- package/lib/matrixrtc/MatrixRTCSession.js.map +1 -1
- package/lib/matrixrtc/MembershipManager.js +7 -4
- package/lib/matrixrtc/MembershipManager.js.map +1 -1
- package/lib/matrixrtc/RTCEncryptionManager.d.ts +17 -0
- package/lib/matrixrtc/RTCEncryptionManager.d.ts.map +1 -1
- package/lib/matrixrtc/RTCEncryptionManager.js +68 -29
- package/lib/matrixrtc/RTCEncryptionManager.js.map +1 -1
- package/lib/models/room-receipts.d.ts +9 -3
- package/lib/models/room-receipts.d.ts.map +1 -1
- package/lib/models/room-receipts.js +51 -11
- package/lib/models/room-receipts.js.map +1 -1
- package/lib/oauth/authorize.d.ts +7 -2
- package/lib/oauth/authorize.d.ts.map +1 -1
- package/lib/oauth/authorize.js +10 -4
- package/lib/oauth/authorize.js.map +1 -1
- package/lib/oauth/fetch.d.ts +17 -0
- package/lib/oauth/fetch.d.ts.map +1 -0
- package/lib/oauth/fetch.js +47 -0
- package/lib/oauth/fetch.js.map +1 -0
- package/lib/oauth/index.d.ts +15 -2
- package/lib/oauth/index.d.ts.map +1 -1
- package/lib/oauth/index.js +33 -12
- package/lib/oauth/index.js.map +1 -1
- package/lib/rust-crypto/index.d.ts +17 -0
- package/lib/rust-crypto/index.d.ts.map +1 -1
- package/lib/rust-crypto/index.js +4 -2
- package/lib/rust-crypto/index.js.map +1 -1
- package/lib/rust-crypto/rust-crypto.d.ts +6 -26
- package/lib/rust-crypto/rust-crypto.d.ts.map +1 -1
- package/lib/rust-crypto/rust-crypto.js +19 -54
- package/lib/rust-crypto/rust-crypto.js.map +1 -1
- package/lib/sliding-sync-sdk.d.ts.map +1 -1
- package/lib/sliding-sync-sdk.js +65 -27
- package/lib/sliding-sync-sdk.js.map +1 -1
- package/lib/sliding-sync.d.ts +17 -0
- package/lib/sliding-sync.d.ts.map +1 -1
- package/lib/sliding-sync.js +21 -9
- package/lib/sliding-sync.js.map +1 -1
- package/lib/sync.d.ts +9 -1
- package/lib/sync.d.ts.map +1 -1
- package/lib/sync.js +54 -29
- package/lib/sync.js.map +1 -1
- package/package.json +5 -5
- package/src/@types/json.ts +11 -2
- package/src/client.ts +126 -11
- package/src/common-crypto/CryptoBackend.ts +45 -23
- package/src/embedded.ts +67 -4
- package/src/http-api/fetch.ts +2 -23
- package/src/http-api/logging.ts +46 -0
- package/src/matrixrtc/EncryptionManager.ts +6 -0
- package/src/matrixrtc/LivekitTransport.ts +86 -0
- package/src/matrixrtc/MatrixRTCSession.ts +81 -1
- package/src/matrixrtc/MembershipManager.ts +7 -7
- package/src/matrixrtc/RTCEncryptionManager.ts +80 -31
- package/src/models/room-receipts.ts +54 -10
- package/src/oauth/authorize.ts +10 -2
- package/src/oauth/fetch.ts +54 -0
- package/src/oauth/index.ts +29 -10
- package/src/rust-crypto/index.ts +23 -0
- package/src/rust-crypto/rust-crypto.ts +25 -67
- package/src/sliding-sync-sdk.ts +73 -33
- package/src/sliding-sync.ts +31 -14
- package/src/sync.ts +58 -35
|
@@ -26,7 +26,7 @@ import { CallMembership } from "./CallMembership.ts";
|
|
|
26
26
|
import { RoomStateEvent } from "../models/room-state.ts";
|
|
27
27
|
import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts";
|
|
28
28
|
import { type CallMembershipIdentityParts, type IEncryptionManager } from "./EncryptionManager.ts";
|
|
29
|
-
import { logDurationSync } from "../utils.ts";
|
|
29
|
+
import { deepCompare, logDurationSync } from "../utils.ts";
|
|
30
30
|
import type {
|
|
31
31
|
Statistics,
|
|
32
32
|
RTCNotificationType,
|
|
@@ -36,6 +36,7 @@ import type {
|
|
|
36
36
|
Transport,
|
|
37
37
|
SlotDescription,
|
|
38
38
|
RtcSlotEventContent,
|
|
39
|
+
RtcSlotEncryptionContent,
|
|
39
40
|
} from "./types.ts";
|
|
40
41
|
import {
|
|
41
42
|
MembershipManagerEvent,
|
|
@@ -65,6 +66,8 @@ export enum MatrixRTCSessionEvent {
|
|
|
65
66
|
MembershipManagerError = "membership_manager_error",
|
|
66
67
|
/** The RTCSession did send a call notification caused by joining the call as the first member */
|
|
67
68
|
DidSendCallNotification = "did_send_call_notification",
|
|
69
|
+
/** Whether the session is too large for the media key to be rotated has changed */
|
|
70
|
+
KeyRotationSuppressedChanged = "key_rotation_suppressed_changed",
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
export type MatrixRTCSessionEventHandlerMap = {
|
|
@@ -83,6 +86,7 @@ export type MatrixRTCSessionEventHandlerMap = {
|
|
|
83
86
|
[MatrixRTCSessionEvent.DidSendCallNotification]: (
|
|
84
87
|
notificationContentNew: { event_id: string } & IRTCNotificationContent,
|
|
85
88
|
) => void;
|
|
89
|
+
[MatrixRTCSessionEvent.KeyRotationSuppressedChanged]: (isKeyRotationSuppressed: boolean) => void;
|
|
86
90
|
};
|
|
87
91
|
|
|
88
92
|
export interface SessionConfig {
|
|
@@ -204,6 +208,17 @@ export interface EncryptionConfig {
|
|
|
204
208
|
*/
|
|
205
209
|
keyRotationGracePeriodMs?: number;
|
|
206
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The number of participants in the session at which the media key will no longer be rotated.
|
|
213
|
+
*
|
|
214
|
+
* Rotating a key requires sending it to every participant device, so in large sessions the cost of rotating
|
|
215
|
+
* on every join/leave becomes prohibitive. At this limit the current key is kept and
|
|
216
|
+
* distributed to new joiners. No new keys are generated for joiners/leavers.
|
|
217
|
+
*
|
|
218
|
+
* Defaults to undefined.
|
|
219
|
+
*/
|
|
220
|
+
keyRotationParticipantLimit?: number;
|
|
221
|
+
|
|
207
222
|
/**
|
|
208
223
|
* The delay (in milliseconds) after a member leaves before we create and publish a new key, because people
|
|
209
224
|
* tend to leave calls at the same time.
|
|
@@ -296,6 +311,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|
|
296
311
|
return this.membershipManager?.delayId;
|
|
297
312
|
}
|
|
298
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Whether the key rotation is currently halted.
|
|
316
|
+
* mirror of: EncryptionConfig.keyRotationParticipantLimit
|
|
317
|
+
*/
|
|
318
|
+
public get isKeyRotationSuppressed(): boolean {
|
|
319
|
+
return this.encryptionManager?.isKeyRotationSuppressed ?? false;
|
|
320
|
+
}
|
|
321
|
+
|
|
299
322
|
/**
|
|
300
323
|
* The callId (sessionId) of the call.
|
|
301
324
|
*
|
|
@@ -335,6 +358,55 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|
|
335
358
|
return isSlotClosed(this.roomSubset, this.slotDescription);
|
|
336
359
|
}
|
|
337
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Ensures this session's slot is open, sending a slot state event to open (or create) it if needed.
|
|
363
|
+
*
|
|
364
|
+
* The event's `application` is set from this session's slot description and its `encryption` from
|
|
365
|
+
* `opts.encryption`, replacing whatever an existing slot event declares. Other content is preserved.
|
|
366
|
+
* No-op if the slot is already open with matching `application` and `encryption`.
|
|
367
|
+
*
|
|
368
|
+
* @param opts.encryption - The encryption to declare on the slot, or `undefined` for none.
|
|
369
|
+
* @throws if sending the state event fails.
|
|
370
|
+
*/
|
|
371
|
+
public async ensureRtcSlotOpen(opts: { encryption?: RtcSlotEncryptionContent } = {}): Promise<void> {
|
|
372
|
+
const application = { type: this.slotDescription.application };
|
|
373
|
+
const existingContent = this.getRtcSlot();
|
|
374
|
+
if (
|
|
375
|
+
existingContent?.status === "open" &&
|
|
376
|
+
deepCompare(existingContent.application, application) &&
|
|
377
|
+
deepCompare(existingContent.encryption, opts.encryption)
|
|
378
|
+
) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
await this.sendRtcSlot({
|
|
382
|
+
...existingContent,
|
|
383
|
+
status: "open",
|
|
384
|
+
application,
|
|
385
|
+
encryption: opts.encryption,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Ensures this session's slot is closed, sending a slot state event to close it if needed.
|
|
391
|
+
* The other content of the existing slot event is preserved. No-op if no slot event exists.
|
|
392
|
+
*
|
|
393
|
+
* @throws if sending the state event fails.
|
|
394
|
+
*/
|
|
395
|
+
public async ensureRtcSlotClosed(): Promise<void> {
|
|
396
|
+
const existingContent = this.getRtcSlot();
|
|
397
|
+
if (!existingContent || existingContent.status === "closed") return;
|
|
398
|
+
await this.sendRtcSlot({ ...existingContent, status: "closed" });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private async sendRtcSlot(content: RtcSlotEventContent): Promise<void> {
|
|
402
|
+
await this.client.sendStateEvent(
|
|
403
|
+
this.roomSubset.roomId,
|
|
404
|
+
EventType.RTCSlot,
|
|
405
|
+
content,
|
|
406
|
+
computeSlotId(this.slotDescription),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
338
410
|
/**
|
|
339
411
|
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
|
340
412
|
* oldest first.
|
|
@@ -810,6 +882,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|
|
810
882
|
// Clear the flag.
|
|
811
883
|
this.membershipNeedsRecalculation = false;
|
|
812
884
|
const oldMemberships = this.memberships;
|
|
885
|
+
// Needs to be computed before `this.memberships` is updated below, since it is derived from it.
|
|
886
|
+
const wasKeyRotationSuppressed = this.isKeyRotationSuppressed;
|
|
813
887
|
|
|
814
888
|
this.memberships = await MatrixRTCSession.sessionMembershipsForSlot(
|
|
815
889
|
this.room,
|
|
@@ -854,6 +928,12 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|
|
854
928
|
// This also needs to be done if `changed` = false
|
|
855
929
|
// A member might have updated their fingerprint (created_ts)
|
|
856
930
|
this.encryptionManager?.onMembershipsUpdate(oldMemberships);
|
|
931
|
+
if (this.isKeyRotationSuppressed !== wasKeyRotationSuppressed) {
|
|
932
|
+
this.logger.info(
|
|
933
|
+
`Key rotation is now ${this.isKeyRotationSuppressed ? "suppressed" : "active again"} (${this.memberships.length} members)`,
|
|
934
|
+
);
|
|
935
|
+
this.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, this.isKeyRotationSuppressed);
|
|
936
|
+
}
|
|
857
937
|
|
|
858
938
|
this.setExpiryTimer();
|
|
859
939
|
};
|
|
@@ -919,15 +919,15 @@ export class MembershipManager
|
|
|
919
919
|
const retryCounterString = "(" + retries + "/" + this.maximumNetworkErrorRetryCount + ")";
|
|
920
920
|
|
|
921
921
|
// Variables for scheduling the new event
|
|
922
|
-
|
|
922
|
+
const retryDuration = this.networkErrorRetryMs;
|
|
923
923
|
|
|
924
924
|
if (error instanceof Error && error.name === "AbortError") {
|
|
925
|
-
//
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
);
|
|
925
|
+
// A local timeout means the server accepted the request but is slow to answer. Retry immediately and
|
|
926
|
+
// do not count it towards the fatal retry limit: the server's delayed leave already bounds how long a
|
|
927
|
+
// stalled restart can go unnoticed, and the resulting forced re-join recovers the membership. Giving
|
|
928
|
+
// up here would only replace a temporary media pause with a "connection lost" error for the user.
|
|
929
|
+
this.logger.warn("Network local timeout error while sending event, immediate retry", error);
|
|
930
|
+
return createInsertActionUpdate(type, 0);
|
|
931
931
|
} else if (error instanceof Error && error.message.includes("updating delayed event")) {
|
|
932
932
|
// TODO: We do not want error message matching here but instead the error should be a typed HTTPError
|
|
933
933
|
// and be handled below automatically (the same as in the SPA case).
|
|
@@ -34,6 +34,17 @@ import {
|
|
|
34
34
|
import { OutdatedKeyFilter } from "./utils.ts";
|
|
35
35
|
import { computeRtcIdentityRaw } from "./membershipData/rtc.ts";
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Default for {@link EncryptionConfig.keyRotationParticipantLimit}.
|
|
39
|
+
*
|
|
40
|
+
* Setting this to undefined implies that we do not have a limit and full rotations are always done.
|
|
41
|
+
* This is the most secure and least performant option.
|
|
42
|
+
* It is highly recommended to set this to < 50 for client deployments that are planned to be used for large calls.
|
|
43
|
+
* But before setting this, make yourself familiar with the exact security implications.
|
|
44
|
+
* Key, rotations will stop when reaching this user limit in a call. The call will still be encrypted.
|
|
45
|
+
*/
|
|
46
|
+
const DEFAULT_KEY_ROTATION_PARTICIPANT_LIMIT: number | undefined = undefined;
|
|
47
|
+
|
|
37
48
|
/**
|
|
38
49
|
* RTCEncryptionManager is used to manage the encryption keys for a call.
|
|
39
50
|
*
|
|
@@ -93,6 +104,14 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
|
|
93
104
|
*/
|
|
94
105
|
private keyRotationGracePeriodMs = 10_000;
|
|
95
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The number of participants at or above which we stop rotating the key altogether.
|
|
109
|
+
* The current key is still distributed to new joiners, but no new key is generated.
|
|
110
|
+
* @see EncryptionConfig.keyRotationParticipantLimit
|
|
111
|
+
* @private
|
|
112
|
+
*/
|
|
113
|
+
private keyRotationParticipantLimit = DEFAULT_KEY_ROTATION_PARTICIPANT_LIMIT;
|
|
114
|
+
|
|
96
115
|
/**
|
|
97
116
|
* If a new key distribution is being requested while one is going on, we will set this flag to true.
|
|
98
117
|
* This will ensure that a new round is started after the current one.
|
|
@@ -141,6 +160,21 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
|
|
141
160
|
this.rtcIdentityProvider = rtcBackendIdProvider ?? computeRtcIdentityRaw;
|
|
142
161
|
}
|
|
143
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Whether the session currently has too many participants for the key to be rotated.
|
|
165
|
+
*
|
|
166
|
+
* This is computed by checking the participant count. If there are too many participants for efficient rotations,
|
|
167
|
+
* the key rotation will be suppressed.
|
|
168
|
+
* While this is true, the current key is still shared with new joiners and the current call is still fully encrypted,
|
|
169
|
+
* but no new key is generated for joiners or leavers. Changes are signalled by {@link MatrixRTCSessionEvent.KeyRotationSuppressedChanged}.
|
|
170
|
+
* @see EncryptionConfig.keyRotationParticipantLimit
|
|
171
|
+
*/
|
|
172
|
+
public get isKeyRotationSuppressed(): boolean {
|
|
173
|
+
if (!this.manageMediaKeys) return false;
|
|
174
|
+
if (this.keyRotationParticipantLimit === undefined) return false;
|
|
175
|
+
return this.getMemberships().length >= this.keyRotationParticipantLimit;
|
|
176
|
+
}
|
|
177
|
+
|
|
144
178
|
private async getOwnRtcBackendIdentity(): Promise<string> {
|
|
145
179
|
if (this.ownRtcBackendIdentityCache) return this.ownRtcBackendIdentityCache;
|
|
146
180
|
|
|
@@ -221,6 +255,8 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
|
|
221
255
|
this.useHashedRtcBackendIdentity = joinConfig?.unstableSendStickyEvents ?? false;
|
|
222
256
|
this.useKeyDelay = joinConfig?.useKeyDelay ?? 1000;
|
|
223
257
|
this.keyRotationGracePeriodMs = joinConfig?.keyRotationGracePeriodMs ?? 10_000;
|
|
258
|
+
this.keyRotationParticipantLimit =
|
|
259
|
+
joinConfig?.keyRotationParticipantLimit ?? DEFAULT_KEY_ROTATION_PARTICIPANT_LIMIT;
|
|
224
260
|
|
|
225
261
|
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
|
226
262
|
void this.getOwnRtcBackendIdentity(); // precompute own identity
|
|
@@ -374,53 +410,66 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
|
|
374
410
|
);
|
|
375
411
|
|
|
376
412
|
let toDistributeTo: ParticipantDeviceInfo[] = [];
|
|
377
|
-
|
|
413
|
+
//default to current session
|
|
414
|
+
let newOutboundEncryptionSession: OutboundEncryptionSession = this.outboundSession!;
|
|
378
415
|
let hasKeyChanged = false;
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (
|
|
390
|
-
// keep the same key
|
|
391
|
-
// XXX In the future we want to distribute a ratcheted key, not the current one
|
|
392
|
-
this.logger?.debug(`New joiners detected, but the key is recent enough (age:${keyAge}), keeping it`);
|
|
393
|
-
toDistributeTo = anyJoined;
|
|
394
|
-
outboundKey = this.outboundSession!;
|
|
395
|
-
} else {
|
|
416
|
+
|
|
417
|
+
// Rotating means sending the new key to every participant, this is expensive in large session.
|
|
418
|
+
if (this.isKeyRotationSuppressed) {
|
|
419
|
+
if (anyJoined.length > 0) {
|
|
420
|
+
this.logger?.debug(
|
|
421
|
+
`New joiners detected, but the session has ${toShareWith.length} participants (limit:${this.keyRotationParticipantLimit}), keeping the key`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
toDistributeTo = anyJoined;
|
|
425
|
+
} else {
|
|
426
|
+
if (anyLeft.length > 0) {
|
|
396
427
|
// We need to rotate the key
|
|
397
|
-
this.
|
|
398
|
-
const newOutboundKey = this.createNewOutboundSession();
|
|
428
|
+
newOutboundEncryptionSession = this.createNewOutboundSession();
|
|
399
429
|
hasKeyChanged = true;
|
|
400
430
|
toDistributeTo = toShareWith;
|
|
401
|
-
|
|
431
|
+
} else if (anyJoined.length > 0) {
|
|
432
|
+
const keyAge = Date.now() - this.outboundSession!.creationTS;
|
|
433
|
+
if (keyAge < this.keyRotationGracePeriodMs) {
|
|
434
|
+
this.logger?.debug(
|
|
435
|
+
`New joiners detected, but the key is recent enough (age:${keyAge}), keeping it`,
|
|
436
|
+
);
|
|
437
|
+
toDistributeTo = anyJoined;
|
|
438
|
+
} else {
|
|
439
|
+
this.logger?.debug(`New joiners detected, rotating the key`);
|
|
440
|
+
// We need to rotate the key
|
|
441
|
+
newOutboundEncryptionSession = this.createNewOutboundSession();
|
|
442
|
+
hasKeyChanged = true;
|
|
443
|
+
toDistributeTo = toShareWith;
|
|
444
|
+
}
|
|
402
445
|
}
|
|
403
|
-
}
|
|
404
|
-
|
|
446
|
+
}
|
|
447
|
+
// return early if we dont have anything to distribute.
|
|
448
|
+
if (toDistributeTo.length === 0) {
|
|
405
449
|
return;
|
|
406
450
|
}
|
|
407
451
|
|
|
408
452
|
try {
|
|
409
453
|
this.logger?.trace(`Sending key...`);
|
|
410
|
-
await this.transport.sendKey(
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
454
|
+
await this.transport.sendKey(
|
|
455
|
+
encodeBase64(newOutboundEncryptionSession.key),
|
|
456
|
+
newOutboundEncryptionSession.keyId,
|
|
457
|
+
toDistributeTo,
|
|
414
458
|
);
|
|
459
|
+
newOutboundEncryptionSession.sharedWith.push(...toDistributeTo);
|
|
460
|
+
const outboundSessionList = newOutboundEncryptionSession.sharedWith
|
|
461
|
+
.map((m) => `${m.userId}:${m.deviceId}`)
|
|
462
|
+
.join(",");
|
|
463
|
+
this.logger?.trace(`key index:${newOutboundEncryptionSession.keyId} sent to ${outboundSessionList}`);
|
|
415
464
|
if (hasKeyChanged) {
|
|
416
465
|
// Delay a bit before using this key
|
|
417
466
|
// It is recommended not to start using a key immediately but instead wait for a short time to make sure it is delivered.
|
|
418
|
-
this.logger?.trace(`Delay Rollout for key:${
|
|
467
|
+
this.logger?.trace(`Delay Rollout for key:${newOutboundEncryptionSession.keyId}...`);
|
|
419
468
|
await sleep(this.useKeyDelay);
|
|
420
|
-
this.logger?.trace(`...Delayed rollout of index:${
|
|
469
|
+
this.logger?.trace(`...Delayed rollout of index:${newOutboundEncryptionSession.keyId} `);
|
|
421
470
|
this.addKeyToParticipantWithBackendIdentity(
|
|
422
|
-
|
|
423
|
-
|
|
471
|
+
newOutboundEncryptionSession.key,
|
|
472
|
+
newOutboundEncryptionSession.keyId,
|
|
424
473
|
this.ownMembership,
|
|
425
474
|
await this.getOwnRtcBackendIdentity(),
|
|
426
475
|
);
|
|
@@ -158,13 +158,22 @@ export class RoomReceipts {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
// consider everything in the thread to be read.
|
|
161
|
+
if (this.userSentEventAfterOrSame(threadId, userId, eventId)) {
|
|
162
|
+
// The user sent an event at or after this one, so they must have
|
|
163
|
+
// seen this one: we consider it read.
|
|
165
164
|
//
|
|
166
|
-
// Note:
|
|
167
|
-
// do this job for us
|
|
165
|
+
// Note: the synthetic receipts we create when we see an event
|
|
166
|
+
// usually do this job for us, but not always - e.g. we don't create
|
|
167
|
+
// them for events we paginated in.
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (event.isThreadRoot && this.userRepliedInThread(eventId, userId)) {
|
|
172
|
+
// The user replied in the thread rooted at this event, and you
|
|
173
|
+
// can't reply to a thread without seeing its root. We have to check
|
|
174
|
+
// this separately because their reply is in the thread's timeline
|
|
175
|
+
// whereas the root is in the main one, so no receipt of theirs can
|
|
176
|
+
// cover the root.
|
|
168
177
|
return true;
|
|
169
178
|
}
|
|
170
179
|
|
|
@@ -173,17 +182,52 @@ export class RoomReceipts {
|
|
|
173
182
|
}
|
|
174
183
|
|
|
175
184
|
/**
|
|
176
|
-
* @returns true if the
|
|
177
|
-
* user sent the
|
|
185
|
+
* @returns true if the timeline for this thread ID can be found, and the
|
|
186
|
+
* supplied user sent an event in it that is the same as, or after,
|
|
187
|
+
* the event with the supplied ID.
|
|
178
188
|
*/
|
|
179
|
-
private
|
|
189
|
+
private userSentEventAfterOrSame(threadId: string, userId: string, eventId: string): boolean {
|
|
180
190
|
const timeline =
|
|
181
191
|
threadId === MAIN_ROOM_TIMELINE
|
|
182
192
|
? this.room.getLiveTimeline().getEvents()
|
|
183
193
|
: this.room.getThread(threadId)?.timeline;
|
|
184
194
|
|
|
185
|
-
|
|
195
|
+
const latestEventId = latestEventIdSentByUser(timeline, userId);
|
|
196
|
+
return !!latestEventId && isAfterOrSame(latestEventId, eventId, this.room);
|
|
186
197
|
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @returns true if the thread rooted at the event with the supplied ID can
|
|
201
|
+
* be found, and the supplied user sent a reply in it.
|
|
202
|
+
*/
|
|
203
|
+
private userRepliedInThread(rootEventId: string, userId: string): boolean {
|
|
204
|
+
// The root is part of the thread's timeline, but it is not a reply to
|
|
205
|
+
// the thread, so leave it out.
|
|
206
|
+
const replies = this.room.getThread(rootEventId)?.timeline.filter((event) => event.getId() !== rootEventId);
|
|
207
|
+
return !!latestEventIdSentByUser(replies, userId);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Find the last event in the supplied timeline that was sent by this user.
|
|
213
|
+
*
|
|
214
|
+
* Events that are still on their way to the server (or failed to get there) are
|
|
215
|
+
* ignored: they have no place in the timeline yet, and a failed send tells us
|
|
216
|
+
* nothing about what the user has seen.
|
|
217
|
+
*
|
|
218
|
+
* @returns the ID of the found event, or undefined if there is no such event.
|
|
219
|
+
*/
|
|
220
|
+
function latestEventIdSentByUser(timeline: Array<MatrixEvent> | undefined, userId: string): string | undefined {
|
|
221
|
+
if (!timeline) return undefined;
|
|
222
|
+
|
|
223
|
+
for (let index = timeline.length - 1; index >= 0; index--) {
|
|
224
|
+
const event = timeline[index];
|
|
225
|
+
if (event.getSender() === userId && !event.status) {
|
|
226
|
+
return event.getId();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return undefined;
|
|
187
231
|
}
|
|
188
232
|
|
|
189
233
|
// --- implementation details ---
|
package/src/oauth/authorize.ts
CHANGED
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
import { Method } from "../http-api/index.ts";
|
|
28
28
|
import { OAuthGrantType } from "./register.ts";
|
|
29
29
|
import { sleep } from "../utils.ts";
|
|
30
|
+
import { type Logger, logger as rootLogger } from "../logger.ts";
|
|
31
|
+
import { fetchWithLogging } from "./fetch.ts";
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* The expected response type from the token endpoint during authorization code flow
|
|
@@ -185,6 +187,7 @@ export function validateDeviceAuthorizationResponse(
|
|
|
185
187
|
* @param options.clientId - the client ID returned from client registration.
|
|
186
188
|
* @param options.scope - the scope to request for authorization.
|
|
187
189
|
* @param options.metadata - the validated OAuth2 metadata for the Identity Provider.
|
|
190
|
+
* @param options.logger - optional logger to use for the request, defaults to the root logger of the js-sdk.
|
|
188
191
|
* @returns a promise that resolves to a device access token response,
|
|
189
192
|
* or an error response if the user denies authorization or the device code expires.
|
|
190
193
|
*/
|
|
@@ -192,10 +195,12 @@ export const startDeviceAuthorization = async ({
|
|
|
192
195
|
clientId,
|
|
193
196
|
scope,
|
|
194
197
|
metadata,
|
|
198
|
+
logger = rootLogger,
|
|
195
199
|
}: {
|
|
196
200
|
clientId: string;
|
|
197
201
|
scope: string;
|
|
198
202
|
metadata: ValidatedAuthMetadata;
|
|
203
|
+
logger?: Logger;
|
|
199
204
|
}): Promise<DeviceAuthorizationResponse> => {
|
|
200
205
|
const body = new URLSearchParams({ client_id: clientId, scope: scope }).toString();
|
|
201
206
|
|
|
@@ -204,7 +209,7 @@ export const startDeviceAuthorization = async ({
|
|
|
204
209
|
throw new Error("No device_authorization_endpoint given");
|
|
205
210
|
}
|
|
206
211
|
|
|
207
|
-
const response = await
|
|
212
|
+
const response = await fetchWithLogging(logger, url, {
|
|
208
213
|
method: Method.Post,
|
|
209
214
|
headers: {
|
|
210
215
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
@@ -223,6 +228,7 @@ export const startDeviceAuthorization = async ({
|
|
|
223
228
|
* @param options.session - The session returned from a previous call to {@link startDeviceAuthorization}.
|
|
224
229
|
* @param options.metadata - The validated OAuth2 metadata for the Identity Provider.
|
|
225
230
|
* @param options.clientId - The client ID returned from client registration.
|
|
231
|
+
* @param options.logger - optional logger to use for the requests, defaults to the root logger of the js-sdk.
|
|
226
232
|
* @returns a promise that resolves to a device access token response,
|
|
227
233
|
* or an error response if the user denies authorization or the device code expires.
|
|
228
234
|
*/
|
|
@@ -230,10 +236,12 @@ export const waitForDeviceAuthorization = async ({
|
|
|
230
236
|
session,
|
|
231
237
|
metadata,
|
|
232
238
|
clientId,
|
|
239
|
+
logger = rootLogger,
|
|
233
240
|
}: {
|
|
234
241
|
session: DeviceAuthorizationResponse;
|
|
235
242
|
metadata: ValidatedAuthMetadata;
|
|
236
243
|
clientId: string;
|
|
244
|
+
logger?: Logger;
|
|
237
245
|
}): Promise<DeviceAccessTokenResponse | DeviceAccessTokenError> => {
|
|
238
246
|
let interval = (session.interval ?? 5) * 1000; // poll interval
|
|
239
247
|
const expiration = Date.now() + session.expires_in * 1000;
|
|
@@ -243,7 +251,7 @@ export const waitForDeviceAuthorization = async ({
|
|
|
243
251
|
grant_type: OAuthGrantType.DeviceAuthorization,
|
|
244
252
|
client_id: clientId,
|
|
245
253
|
}).toString();
|
|
246
|
-
const response = await
|
|
254
|
+
const response = await fetchWithLogging(logger, metadata.token_endpoint, {
|
|
247
255
|
method: Method.Post,
|
|
248
256
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
249
257
|
body,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2026 The Matrix.org Foundation C.I.C.
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { sanitizeUrlForLogs } from "../http-api/logging.ts";
|
|
18
|
+
import { Method } from "../http-api/method.ts";
|
|
19
|
+
import { type Logger } from "../logger.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Perform a `fetch` request, logging the request and response in the same manner as
|
|
23
|
+
* `FetchHttpApi` does for Client-Server API requests.
|
|
24
|
+
*
|
|
25
|
+
* Neither the request nor the response body is logged, as they routinely contain credentials.
|
|
26
|
+
* Query parameter values are redacted for the same reason.
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
* @param logger - the logger to write the request and response lines to.
|
|
30
|
+
* @param resource - the URL to request.
|
|
31
|
+
* @param options - the options to pass to `fetch`.
|
|
32
|
+
* @returns the `Response`, whatever its status code.
|
|
33
|
+
* @throws rethrows whatever `fetch` threw, having logged it.
|
|
34
|
+
*/
|
|
35
|
+
export async function fetchWithLogging(
|
|
36
|
+
logger: Logger,
|
|
37
|
+
resource: URL | string,
|
|
38
|
+
options: RequestInit = {},
|
|
39
|
+
): Promise<Response> {
|
|
40
|
+
const method = options.method ?? Method.Get;
|
|
41
|
+
const urlForLogs = sanitizeUrlForLogs(resource);
|
|
42
|
+
|
|
43
|
+
logger.debug(`OAuth2: --> ${method} ${urlForLogs}`);
|
|
44
|
+
|
|
45
|
+
const start = Date.now();
|
|
46
|
+
try {
|
|
47
|
+
const res = await globalThis.fetch(resource, options);
|
|
48
|
+
logger.debug(`OAuth2: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${res.status}]`);
|
|
49
|
+
return res;
|
|
50
|
+
} catch (e) {
|
|
51
|
+
logger.debug(`OAuth2: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${e}]`);
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -35,7 +35,8 @@ import {
|
|
|
35
35
|
import { encodeUnpaddedBase64Url } from "../base64.ts";
|
|
36
36
|
import { sha256 } from "../digest.ts";
|
|
37
37
|
import { HTTPError, isMatrixErrorResponse, MatrixError, Method } from "../http-api/index.ts";
|
|
38
|
-
import { logger } from "../logger.ts";
|
|
38
|
+
import { type Logger, logger as rootLogger } from "../logger.ts";
|
|
39
|
+
import { fetchWithLogging } from "./fetch.ts";
|
|
39
40
|
import { isOAuth2ErrorResponse, OAuth2Error, OAuth2HTTPError } from "./error.ts";
|
|
40
41
|
import { secureRandomString } from "../randomstring.ts";
|
|
41
42
|
import { type NonEmptyArray } from "../@types/common.ts";
|
|
@@ -67,12 +68,14 @@ export class OAuth2 {
|
|
|
67
68
|
* @param authMetadata - Auth config from {@link MatrixClient.getAuthMetadata}
|
|
68
69
|
* @param clientMetadata - The metadata for the client which to register,
|
|
69
70
|
* grant_types & response_types & token_endpoint_auth_method will be sanely calculated if omitted.
|
|
71
|
+
* @param logger - Optional logger to use for the request, defaults to the root logger of the js-sdk.
|
|
70
72
|
* @returns Promise<string> resolved with registered clientId
|
|
71
73
|
* @throws when registration is not supported, on failed request or invalid response
|
|
72
74
|
*/
|
|
73
75
|
public static async registerClient(
|
|
74
76
|
authMetadata: ValidatedAuthMetadata,
|
|
75
77
|
clientMetadata: OAuthRegistrationRequest,
|
|
78
|
+
logger: Logger = rootLogger,
|
|
76
79
|
): Promise<string> {
|
|
77
80
|
const defaultGrantTypes: NonEmptyArray<string> = [
|
|
78
81
|
OAuthGrantType.AuthorizationCode,
|
|
@@ -102,7 +105,7 @@ export class OAuth2 {
|
|
|
102
105
|
};
|
|
103
106
|
|
|
104
107
|
try {
|
|
105
|
-
const response = await
|
|
108
|
+
const response = await fetchWithLogging(logger, authMetadata.registration_endpoint, {
|
|
106
109
|
method: Method.Post,
|
|
107
110
|
headers: {
|
|
108
111
|
"Accept": "application/json",
|
|
@@ -133,9 +136,16 @@ export class OAuth2 {
|
|
|
133
136
|
|
|
134
137
|
public readonly context: Required<Context>;
|
|
135
138
|
|
|
139
|
+
/**
|
|
140
|
+
* @param metadata - The validated OAuth 2.0 metadata for the Identity Provider.
|
|
141
|
+
* @param context - The persistent context needed for the OAuth flows.
|
|
142
|
+
* @param logger - Optional logger to use for the requests made by this instance,
|
|
143
|
+
* defaults to the root logger of the js-sdk.
|
|
144
|
+
*/
|
|
136
145
|
public constructor(
|
|
137
146
|
public readonly metadata: ValidatedAuthMetadata,
|
|
138
147
|
context: Context,
|
|
148
|
+
private readonly logger: Logger = rootLogger,
|
|
139
149
|
) {
|
|
140
150
|
this.context = {
|
|
141
151
|
clientId: context.clientId,
|
|
@@ -200,7 +210,8 @@ export class OAuth2 {
|
|
|
200
210
|
params.append("redirect_uri", this.context.redirectUri);
|
|
201
211
|
params.append("code", code);
|
|
202
212
|
|
|
203
|
-
const
|
|
213
|
+
const res = await this.fetch("token", params, OAuth2Error.CodeExchangeFailed);
|
|
214
|
+
const tokenResponse = await res.json();
|
|
204
215
|
|
|
205
216
|
// throws when response is invalid
|
|
206
217
|
validateBearerTokenResponse(tokenResponse);
|
|
@@ -217,7 +228,8 @@ export class OAuth2 {
|
|
|
217
228
|
params.append("client_id", this.context.clientId);
|
|
218
229
|
params.append("refresh_token", refreshToken);
|
|
219
230
|
|
|
220
|
-
const
|
|
231
|
+
const res = await this.fetch("token", params, OAuth2Error.RefreshTokenFailed);
|
|
232
|
+
const tokenResponse = await res.json();
|
|
221
233
|
|
|
222
234
|
// throws when response is invalid
|
|
223
235
|
validateBearerTokenResponse(tokenResponse);
|
|
@@ -237,10 +249,11 @@ export class OAuth2 {
|
|
|
237
249
|
params.append("token_type_hint", type);
|
|
238
250
|
}
|
|
239
251
|
|
|
252
|
+
// [RFC 7009 section 2.2](https://www.rfc-editor.org/rfc/rfc7009.html#section-2.2) says:
|
|
253
|
+
// > The content of the response body is ignored by the client as all
|
|
254
|
+
// > necessary information is conveyed in the response code.
|
|
255
|
+
// so, we don't do anything with the response body.
|
|
240
256
|
await this.fetch("revocation", params, OAuth2Error.RevokeTokenFailed);
|
|
241
|
-
|
|
242
|
-
const headers = new Headers();
|
|
243
|
-
headers.set("Content-Type", "application/x-www-form-urlencoded");
|
|
244
257
|
}
|
|
245
258
|
|
|
246
259
|
/**
|
|
@@ -254,6 +267,7 @@ export class OAuth2 {
|
|
|
254
267
|
scope: scope ?? generateScope(this.context.deviceId),
|
|
255
268
|
metadata: this.metadata,
|
|
256
269
|
clientId: this.context.clientId,
|
|
270
|
+
logger: this.logger,
|
|
257
271
|
});
|
|
258
272
|
}
|
|
259
273
|
|
|
@@ -270,16 +284,21 @@ export class OAuth2 {
|
|
|
270
284
|
session,
|
|
271
285
|
metadata: this.metadata,
|
|
272
286
|
clientId: this.context.clientId,
|
|
287
|
+
logger: this.logger,
|
|
273
288
|
});
|
|
274
289
|
}
|
|
275
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Make a request to one of the OAuth 2.0 endpoints, throwing if it responds with an error.
|
|
293
|
+
* @returns the {@link Response}, for the caller to parse if the endpoint returns a body.
|
|
294
|
+
*/
|
|
276
295
|
private async fetch(
|
|
277
296
|
target: "token" | "registration" | "revocation",
|
|
278
297
|
params: URLSearchParams,
|
|
279
298
|
error: OAuth2Error,
|
|
280
|
-
): Promise<
|
|
299
|
+
): Promise<Response> {
|
|
281
300
|
const url = this.metadata[`${target}_endpoint`];
|
|
282
|
-
const res = await
|
|
301
|
+
const res = await fetchWithLogging(this.logger, url, {
|
|
283
302
|
method: Method.Post,
|
|
284
303
|
headers: {
|
|
285
304
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
@@ -309,6 +328,6 @@ export class OAuth2 {
|
|
|
309
328
|
throw new HTTPError(error, res.status, res.headers);
|
|
310
329
|
}
|
|
311
330
|
|
|
312
|
-
return
|
|
331
|
+
return res;
|
|
313
332
|
}
|
|
314
333
|
}
|