security: bind TURN grants to screen sessions
This commit is contained in:
@@ -19,6 +19,10 @@ import {
|
||||
type ScreenShareStreamCursor,
|
||||
} from "./signalingTypes.ts";
|
||||
import type { MediaAuthorizationDecision } from "./types.ts";
|
||||
import {
|
||||
fetchEphemeralIceConfiguration,
|
||||
type EphemeralIceConfiguration,
|
||||
} from "./iceClient.ts";
|
||||
|
||||
export type RemoteMediaRole = ScreenShareRole;
|
||||
export type RemoteMediaStatus = "idle" | "connecting" | "live" | "reconnecting" | "stopped" | "revoked" | "disposed";
|
||||
@@ -33,7 +37,7 @@ export interface RemoteMediaState {
|
||||
reconnectAttempt: number;
|
||||
hasRemoteVideo: boolean;
|
||||
}
|
||||
export interface RemoteMediaEndpoints { join: string; signal: string; events: string; leave: string }
|
||||
export interface RemoteMediaEndpoints { join: string; signal: string; events: string; leave: string; ice: string }
|
||||
export interface RemoteMediaTimers {
|
||||
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
||||
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
||||
@@ -45,7 +49,11 @@ export interface RemoteOfficeMediaOptions {
|
||||
authenticatedFetch?: typeof globalThis.fetch;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
peerConnectionFactory?: (configuration?: RTCConfiguration) => RTCPeerConnection;
|
||||
peerConnectionConfiguration?: RTCConfiguration;
|
||||
/** Injectable for tests/deployments; called only with an active in-memory grant. */
|
||||
iceConfigurationProvider?(authorization: {
|
||||
binding: ScreenShareBinding;
|
||||
credential: ScreenShareCredential;
|
||||
}): Promise<EphemeralIceConfiguration>;
|
||||
endpoints?: Partial<RemoteMediaEndpoints>;
|
||||
timers?: RemoteMediaTimers;
|
||||
now?: () => number;
|
||||
@@ -95,6 +103,7 @@ const ENDPOINTS: RemoteMediaEndpoints = {
|
||||
signal: "/api/v1/media/signal",
|
||||
events: "/api/v1/media/events",
|
||||
leave: "/api/v1/media/leave",
|
||||
ice: "/api/v1/media/ice",
|
||||
};
|
||||
const TIMERS: RemoteMediaTimers = {
|
||||
setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay),
|
||||
@@ -144,6 +153,14 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
const now = options.now ?? Date.now;
|
||||
const reconnectBase = options.reconnectBaseMs ?? 500;
|
||||
const reconnectMaximum = options.reconnectMaximumMs ?? 15_000;
|
||||
const provideIce = options.iceConfigurationProvider ?? ((authorization) =>
|
||||
fetchEphemeralIceConfiguration({
|
||||
authenticatedFetch: fetcher,
|
||||
endpoint: endpoints.ice,
|
||||
now,
|
||||
binding: authorization.binding,
|
||||
credential: authorization.credential,
|
||||
}));
|
||||
if (!(reconnectBase > 0) || !Number.isFinite(reconnectBase) || reconnectMaximum < reconnectBase) {
|
||||
throw new RangeError("remote media: invalid reconnect bounds");
|
||||
}
|
||||
@@ -157,6 +174,8 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
let presenter: PresenterStart | null = null;
|
||||
let viewer: ViewerStart | null = null;
|
||||
let receiverStream: MediaStream | null = null;
|
||||
let iceConfiguration: EphemeralIceConfiguration | null = null;
|
||||
let iceAuthorization: ScreenShareCredential | null = null;
|
||||
let eventAbort: AbortController | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let renewalTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -225,6 +244,8 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
renewalTimer = null;
|
||||
for (const id of [...peers.keys()]) closePeer(id);
|
||||
releaseReceiver();
|
||||
iceConfiguration = null;
|
||||
iceAuthorization = null;
|
||||
}
|
||||
|
||||
function resetPeerMesh(): void {
|
||||
@@ -232,6 +253,33 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
releaseReceiver();
|
||||
}
|
||||
|
||||
async function refreshIceConfiguration(expectedGrant: ScreenShareAccessGrant): Promise<void> {
|
||||
const activeGeneration = generation;
|
||||
const next = await provideIce({ binding: { ...binding }, credential: { ...expectedGrant.credential } });
|
||||
if (generation !== activeGeneration || grant !== expectedGrant || !isScreenShareGrantActive(expectedGrant, now())) {
|
||||
throw new Error("remote media: ICE configuration was superseded");
|
||||
}
|
||||
if (!Number.isSafeInteger(next.expiresAtMs) || next.expiresAtMs <= now() || !next.configuration.iceServers?.length) {
|
||||
throw new Error("remote media: ICE configuration is not active");
|
||||
}
|
||||
iceConfiguration = {
|
||||
configuration: { ...next.configuration, iceServers: next.configuration.iceServers.map((server) => ({ ...server })) },
|
||||
expiresAtMs: next.expiresAtMs,
|
||||
};
|
||||
iceAuthorization = { ...expectedGrant.credential };
|
||||
}
|
||||
|
||||
async function ensureIceConfiguration(): Promise<void> {
|
||||
if (!grant) throw new Error("remote media: signaling grant is missing");
|
||||
const credential = grant.credential;
|
||||
if (iceConfiguration && iceConfiguration.expiresAtMs > now() && iceAuthorization &&
|
||||
iceAuthorization.sessionId === credential.sessionId &&
|
||||
iceAuthorization.participantId === credential.participantId &&
|
||||
iceAuthorization.role === credential.role &&
|
||||
iceAuthorization.grantToken === credential.grantToken) return;
|
||||
await refreshIceConfiguration(grant);
|
||||
}
|
||||
|
||||
async function sendSignal(targetParticipantId: string, signal: ScreenShareSignalPayload): Promise<void> {
|
||||
if (!grant) throw new Error("remote media: signaling grant is missing");
|
||||
const request: ScreenShareClientMessage = {
|
||||
@@ -250,7 +298,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
const prior = peers.get(participant.participantId);
|
||||
if (prior) return prior;
|
||||
const activeGeneration = generation;
|
||||
const connection = makePeer(options.peerConnectionConfiguration);
|
||||
if (!iceConfiguration || iceConfiguration.expiresAtMs <= now()) {
|
||||
throw new Error("remote media: active ICE configuration is required before creating a peer");
|
||||
}
|
||||
const connection = makePeer(iceConfiguration.configuration);
|
||||
const slot: PeerSlot = { id: participant.participantId, connection, pendingIce: [] };
|
||||
peers.set(slot.id, slot);
|
||||
connection.onicecandidate = (event) => {
|
||||
@@ -305,6 +356,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
const ids = new Set(allowed.map((item) => item.participantId));
|
||||
for (const id of [...peers.keys()]) if (!ids.has(id)) closePeer(id);
|
||||
participants = allowed;
|
||||
if (allowed.some((participant) => !peers.has(participant.participantId))) await ensureIceConfiguration();
|
||||
if (options.role === "presenter") {
|
||||
for (const participant of allowed) if (!peers.has(participant.participantId)) await offer(participant);
|
||||
} else {
|
||||
@@ -343,6 +395,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
if (message.targetParticipantId !== grant.credential.participantId) return;
|
||||
const from = participants.find((item) => item.participantId === message.fromParticipantId);
|
||||
if (!from) return;
|
||||
if (!peers.has(from.participantId)) await ensureIceConfiguration();
|
||||
const connection = configurePeer(from).connection;
|
||||
if (message.signal.kind === "ice-complete") {
|
||||
if (connection.remoteDescription) await connection.addIceCandidate(null);
|
||||
@@ -508,6 +561,9 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
grant = response.grant;
|
||||
clientSequence = response.nextClientSequence;
|
||||
cursor = { sessionId: response.grant.credential.sessionId, sequence: response.sequence, timestampMs: response.timestampMs };
|
||||
// Signaling authorization always precedes TURN. This is the first point at
|
||||
// which an ICE request can carry a server-issued credential and binding.
|
||||
await refreshIceConfiguration(response.grant);
|
||||
await reconcile(response.participants);
|
||||
scheduleRenewal();
|
||||
if (role === "presenter" && peers.size === 0) setStatus("live");
|
||||
@@ -532,8 +588,11 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
await join("presenter", generation);
|
||||
} catch (reason) {
|
||||
if (status === "connecting") {
|
||||
closeTransport();
|
||||
grant = null;
|
||||
// A join can succeed while the grant-bound TURN exchange fails. End
|
||||
// that just-created hosted session with its in-memory capability
|
||||
// instead of abandoning it until lease expiry. Caller capture remains
|
||||
// untouched and can continue as a local preview.
|
||||
try { await leave(); } catch { closeTransport(); grant = null; participants = []; }
|
||||
presenter = null;
|
||||
setStatus("idle");
|
||||
}
|
||||
@@ -564,8 +623,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
await join("viewer", generation);
|
||||
} catch (reason) {
|
||||
if (status === "connecting") {
|
||||
closeTransport();
|
||||
grant = null;
|
||||
try { await leave(); } catch { closeTransport(); grant = null; participants = []; }
|
||||
viewer = null;
|
||||
setStatus("idle");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user