security: bind TURN grants to screen sessions
This commit is contained in:
+33
-5
@@ -118,14 +118,39 @@ then rate-limit issuance by both subject and trusted client IP. The presenter or
|
|||||||
viewer must join signaling before requesting ICE configuration. GET/query-string
|
viewer must join signaling before requesting ICE configuration. GET/query-string
|
||||||
credentials remain forbidden.
|
credentials remain forbidden.
|
||||||
|
|
||||||
|
Coturn removes the expiry prefix from a REST username before applying
|
||||||
|
`user-quota`. The suffix therefore must be a stable, opaque, session-participant
|
||||||
|
accounting key across repeat ICE issuance for that participant. Do not use the
|
||||||
|
authentication subject, email, profile id or another identity in this suffix,
|
||||||
|
and do not generate a fresh random suffix for every request: the former leaks
|
||||||
|
identity into TURN state/logs, while the latter gives every issuance a fresh
|
||||||
|
`user-quota`. A keyed digest of the opaque signaling session and participant ids
|
||||||
|
is an appropriate suffix. Application-side subject/IP issuance and concurrent
|
||||||
|
session limits remain mandatory; coturn cannot infer a Lumbridge member.
|
||||||
|
|
||||||
|
The template's `max-bps` and `bps-capacity` units are **bytes per second**, with
|
||||||
|
input and output accounted separately. In particular, `bps-capacity=500000000`
|
||||||
|
is a 500 MB/s (roughly 4 Gbit/s) ceiling, not a recommended launch budget.
|
||||||
|
Before public enablement, the operator must lower `total-quota` and
|
||||||
|
`bps-capacity` to the measured instance/network ceiling and an explicitly
|
||||||
|
accepted relay-egress budget. Start below that budget and raise only from
|
||||||
|
observed concurrent screen bitrate and allocation data. The 1,024-port relay
|
||||||
|
range is a hard capacity boundary, not evidence that 900 simultaneous relays are
|
||||||
|
operationally or financially safe.
|
||||||
|
|
||||||
An accepted response uses:
|
An accepted response uses:
|
||||||
|
|
||||||
- username: `<expiry-unix-seconds>:<random-opaque-nonce>`
|
- username: `<expiry-unix-seconds>:<stable-opaque-participant-accounting-key>`
|
||||||
- credential: Base64 HMAC-SHA1 of that username using `TERA_TURN_SHARED_SECRET`
|
- credential: Base64 HMAC-SHA1 of that username using `TERA_TURN_SHARED_SECRET`
|
||||||
- response: `Cache-Control: private, no-store`
|
- response: `Cache-Control: private, no-store`
|
||||||
|
|
||||||
The shared secret never leaves the server. Expiry prevents new allocations and
|
The shared secret never leaves the server. Expiry prevents a credential from
|
||||||
refreshes; it cannot instantly terminate an allocation that already exists.
|
starting a newly authenticated TURN session. It does not terminate an allocation
|
||||||
|
that already exists, and coturn may continue accepting authenticated refreshes
|
||||||
|
within that established session. `stale-nonce` rotates coturn's protocol nonce
|
||||||
|
(the browser handles the 438 challenge); it is not credential revocation. Stop
|
||||||
|
and revoke must still close browser peer connections, while coturn's total
|
||||||
|
allocation and bandwidth limits bound residual abuse.
|
||||||
|
|
||||||
Install `tera-coturn-preflight` as `/usr/local/libexec/tera-coturn-preflight`
|
Install `tera-coturn-preflight` as `/usr/local/libexec/tera-coturn-preflight`
|
||||||
mode `0755`, and install the reviewed systemd drop-in only after confirming the
|
mode `0755`, and install the reviewed systemd drop-in only after confirming the
|
||||||
@@ -205,10 +230,13 @@ Verify through `getStats()` or browser WebRTC diagnostics that:
|
|||||||
- the relay address is the current public IP, never the private VNIC address;
|
- the relay address is the current public IP, never the private VNIC address;
|
||||||
- two peers on different networks can exchange a screen track;
|
- two peers on different networks can exchange a screen track;
|
||||||
- blocking UDP forces TURN/TCP 3478, then TURN/TLS 5349;
|
- blocking UDP forces TURN/TCP 3478, then TURN/TLS 5349;
|
||||||
- expired TURN credentials cannot create or refresh allocations;
|
- expired TURN credentials cannot start a new authenticated allocation;
|
||||||
|
- repeated ICE issuance for one signaling participant retains one opaque TURN
|
||||||
|
accounting suffix and the fifth concurrent allocation is refused by
|
||||||
|
`user-quota=4`, while a different participant remains independent;
|
||||||
- signaling revocation closes the application's peer connections, but an already-issued
|
- signaling revocation closes the application's peer connections, but an already-issued
|
||||||
stateless TURN credential remains usable until its short expiry (and an existing
|
stateless TURN credential remains usable until its short expiry (and an existing
|
||||||
allocation until coturn's configured allocation lifetime);
|
authenticated allocation may remain refreshable until the client disconnects);
|
||||||
- presenter stop and viewer leave close their browser peer connections;
|
- presenter stop and viewer leave close their browser peer connections;
|
||||||
- no CSP or Permissions Policy violation appears on either Tera entry host.
|
- no CSP or Permissions Policy violation appears on either Tera entry host.
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,14 @@ pkey=/etc/coturn/certs/turn.privkey.pem
|
|||||||
min-port=52000
|
min-port=52000
|
||||||
max-port=53023
|
max-port=53023
|
||||||
|
|
||||||
# One credential may briefly own several allocations during ICE restart. The
|
# Coturn keys user-quota by the REST username suffix after the expiry separator.
|
||||||
# total stays below the 1,024-port relay range; tune only from observed usage.
|
# The Tera issuer must reuse one opaque participant-scoped accounting suffix
|
||||||
|
# across that participant's credential renewals; a fresh suffix per request would
|
||||||
|
# evade this limit. The total stays below the 1,024-port relay range.
|
||||||
user-quota=4
|
user-quota=4
|
||||||
total-quota=900
|
total-quota=900
|
||||||
|
# Bytes per second, not bits. 500,000,000 B/s is about 4 Gbit/s; lower both
|
||||||
|
# global limits to an operator-approved NIC and egress-cost budget before launch.
|
||||||
max-bps=2000000
|
max-bps=2000000
|
||||||
bps-capacity=500000000
|
bps-capacity=500000000
|
||||||
|
|
||||||
|
|||||||
+68
-28
@@ -1,4 +1,4 @@
|
|||||||
import { createHmac, randomBytes } from "node:crypto";
|
import { createHmac } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
ICE_CONFIG_PROTOCOL_VERSION,
|
ICE_CONFIG_PROTOCOL_VERSION,
|
||||||
type IceConfigGrant,
|
type IceConfigGrant,
|
||||||
@@ -13,13 +13,19 @@ export type IceCredentialResult =
|
|||||||
| { ok: true; value: IceConfigGrant }
|
| { ok: true; value: IceConfigGrant }
|
||||||
| { ok: false; code: "unavailable" | "rate-limited"; value: IceConfigUnavailable };
|
| { ok: false; code: "unavailable" | "rate-limited"; value: IceConfigUnavailable };
|
||||||
|
|
||||||
|
export interface IceCredentialRateIdentity {
|
||||||
|
/** Used transiently to derive a keyed rate bucket; never retained. */
|
||||||
|
subject: string;
|
||||||
|
/** Fastify's trusted client address; used transiently and never retained. */
|
||||||
|
trustedIp: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IceCredentialProvider {
|
export interface IceCredentialProvider {
|
||||||
issue(request: IceConfigRequest, rateKey: string): IceCredentialResult;
|
issue(request: IceConfigRequest, rateIdentity: IceCredentialRateIdentity, authorizationExpiresAtMs: number): IceCredentialResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IceCredentialProviderOptions {
|
export interface IceCredentialProviderOptions {
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
nonce?: () => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RateEntry { startedAtMs: number; count: number }
|
interface RateEntry { startedAtMs: number; count: number }
|
||||||
@@ -30,37 +36,39 @@ export function createIceCredentialProvider(
|
|||||||
options: IceCredentialProviderOptions = {},
|
options: IceCredentialProviderOptions = {},
|
||||||
): IceCredentialProvider {
|
): IceCredentialProvider {
|
||||||
const now = options.now ?? Date.now;
|
const now = options.now ?? Date.now;
|
||||||
const nonce = options.nonce ?? (() => randomBytes(18).toString("base64url"));
|
const subjectRates = new Map<string, RateEntry>();
|
||||||
const rates = new Map<string, RateEntry>();
|
const ipRates = new Map<string, RateEntry>();
|
||||||
const windowMs = config.rateWindowSeconds * 1_000;
|
const windowMs = config.rateWindowSeconds * 1_000;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
issue(request, rateKey) {
|
issue(request, rateIdentity, authorizationExpiresAtMs) {
|
||||||
const at = now();
|
const at = now();
|
||||||
if (!config.configured) return unavailable(request.requestId, windowMs, "unavailable");
|
if (!config.configured) return unavailable(request.requestId, windowMs, "unavailable");
|
||||||
let entry = rates.get(rateKey);
|
// Derive immediately and retain only keyed digests in the bounded maps.
|
||||||
if (!entry && rates.size >= MAX_RATE_KEYS) {
|
const rateKeys = deriveIceCredentialRateKeys(config.sharedSecret, rateIdentity);
|
||||||
for (const [key, value] of rates) {
|
const subjectRetry = rate(subjectRates, rateKeys.subject, at);
|
||||||
if (at - value.startedAtMs >= windowMs) rates.delete(key);
|
const ipRetry = rate(ipRates, rateKeys.ip, at);
|
||||||
}
|
const retryAfterMs = Math.max(subjectRetry ?? 0, ipRetry ?? 0);
|
||||||
if (rates.size >= MAX_RATE_KEYS) {
|
if (retryAfterMs > 0) return unavailable(request.requestId, retryAfterMs, "rate-limited");
|
||||||
return unavailable(request.requestId, windowMs, "rate-limited");
|
|
||||||
}
|
|
||||||
entry = rates.get(rateKey);
|
|
||||||
}
|
|
||||||
if (!entry || at - entry.startedAtMs >= windowMs) {
|
|
||||||
rates.set(rateKey, { startedAtMs: at, count: 1 });
|
|
||||||
} else {
|
|
||||||
entry.count += 1;
|
|
||||||
if (entry.count > config.rateAttempts) {
|
|
||||||
return unavailable(request.requestId, Math.max(1_000, windowMs - (at - entry.startedAtMs)), "rate-limited");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresAtSeconds = Math.floor(at / 1_000) + config.credentialTtlSeconds;
|
const expiresAtSeconds = Math.min(
|
||||||
// Coturn REST convention: expiration timestamp, colon, opaque username.
|
Math.floor(at / 1_000) + config.credentialTtlSeconds,
|
||||||
// No auth subject, email, profile id, IP address, or stable browser id.
|
Math.floor(authorizationExpiresAtMs / 1_000),
|
||||||
const username = `${expiresAtSeconds}:${nonce()}`;
|
);
|
||||||
|
if (expiresAtSeconds <= Math.floor(at / 1_000)) {
|
||||||
|
return unavailable(request.requestId, 1_000, "unavailable");
|
||||||
|
}
|
||||||
|
// Coturn applies user-quota after stripping the REST expiry prefix. A new
|
||||||
|
// random suffix per issuance would therefore evade quota. This keyed,
|
||||||
|
// domain-separated suffix is stable only for one opaque media participant
|
||||||
|
// and contains no auth subject, email, profile id, or IP address.
|
||||||
|
const opaqueParticipant = createHmac("sha256", config.sharedSecret)
|
||||||
|
.update("tera-turn-user-v1\0")
|
||||||
|
.update(request.credential.sessionId)
|
||||||
|
.update("\0")
|
||||||
|
.update(request.credential.participantId)
|
||||||
|
.digest("base64url");
|
||||||
|
const username = `${expiresAtSeconds}:${opaqueParticipant}`;
|
||||||
const credential = createHmac("sha1", config.sharedSecret).update(username).digest("base64");
|
const credential = createHmac("sha1", config.sharedSecret).update(username).digest("base64");
|
||||||
const stunUrls = config.urls.filter((url) => url.startsWith("stun:") || url.startsWith("stuns:"));
|
const stunUrls = config.urls.filter((url) => url.startsWith("stun:") || url.startsWith("stuns:"));
|
||||||
const turnUrls = config.urls.filter((url) => url.startsWith("turn:") || url.startsWith("turns:"));
|
const turnUrls = config.urls.filter((url) => url.startsWith("turn:") || url.startsWith("turns:"));
|
||||||
@@ -80,6 +88,38 @@ export function createIceCredentialProvider(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function rate(entries: Map<string, RateEntry>, key: string, at: number): number | null {
|
||||||
|
let entry = entries.get(key);
|
||||||
|
if (!entry && entries.size >= MAX_RATE_KEYS) {
|
||||||
|
for (const [candidate, value] of entries) {
|
||||||
|
if (at - value.startedAtMs >= windowMs) entries.delete(candidate);
|
||||||
|
}
|
||||||
|
if (entries.size >= MAX_RATE_KEYS) return windowMs;
|
||||||
|
entry = entries.get(key);
|
||||||
|
}
|
||||||
|
if (!entry || at - entry.startedAtMs >= windowMs) {
|
||||||
|
entries.set(key, { startedAtMs: at, count: 1 });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
entry.count += 1;
|
||||||
|
return entry.count > config.rateAttempts
|
||||||
|
? Math.max(1_000, windowMs - (at - entry.startedAtMs))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-only keyed pseudonyms for the two independent limiter dimensions. */
|
||||||
|
export function deriveIceCredentialRateKeys(
|
||||||
|
sharedSecret: string,
|
||||||
|
identity: IceCredentialRateIdentity,
|
||||||
|
): { subject: string; ip: string } {
|
||||||
|
const derive = (domain: string, value: string) => createHmac("sha256", sharedSecret)
|
||||||
|
.update(domain).update("\0").update(value).digest("hex");
|
||||||
|
return {
|
||||||
|
subject: derive("tera-ice-subject-v1", identity.subject),
|
||||||
|
ip: derive("tera-ice-ip-v1", identity.trustedIp),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function unavailable(
|
function unavailable(
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export {
|
|||||||
export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts";
|
export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts";
|
||||||
export {
|
export {
|
||||||
createIceCredentialProvider,
|
createIceCredentialProvider,
|
||||||
|
deriveIceCredentialRateKeys,
|
||||||
type IceCredentialProvider,
|
type IceCredentialProvider,
|
||||||
type IceCredentialProviderOptions,
|
type IceCredentialProviderOptions,
|
||||||
|
type IceCredentialRateIdentity,
|
||||||
type IceCredentialResult,
|
type IceCredentialResult,
|
||||||
} from "./ice.ts";
|
} from "./ice.ts";
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface MediaSignalService {
|
|||||||
signal(request: ScreenShareSignalRequest, subject: string): MediaSignalResult<null>;
|
signal(request: ScreenShareSignalRequest, subject: string): MediaSignalResult<null>;
|
||||||
leave(request: ScreenShareStopRequest | ScreenShareRevokeRequest, subject: string, admin: boolean): MediaSignalResult<null>;
|
leave(request: ScreenShareStopRequest | ScreenShareRevokeRequest, subject: string, admin: boolean): MediaSignalResult<null>;
|
||||||
subscribe(credential: ScreenShareCredential, subject: string, listener: (message: ScreenSharePeerMessage) => void): MediaSignalResult<() => void>;
|
subscribe(credential: ScreenShareCredential, subject: string, listener: (message: ScreenSharePeerMessage) => void): MediaSignalResult<() => void>;
|
||||||
|
authorizeIce(credential: ScreenShareCredential, binding: ScreenShareBinding, subject: string): MediaSignalResult<{ expiresAtMs: number }>;
|
||||||
revalidate(credential: ScreenShareCredential, subject: string): MediaSignalResult<null>;
|
revalidate(credential: ScreenShareCredential, subject: string): MediaSignalResult<null>;
|
||||||
revokeSubject(subject: string): number;
|
revokeSubject(subject: string): number;
|
||||||
cleanup(): number;
|
cleanup(): number;
|
||||||
@@ -373,6 +374,19 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {}
|
|||||||
return found.ok ? { ok: true, value: null } : found;
|
return found.ok ? { ok: true, value: null } : found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function authorizeIce(
|
||||||
|
raw: ScreenShareCredential,
|
||||||
|
binding: ScreenShareBinding,
|
||||||
|
subject: string,
|
||||||
|
): MediaSignalResult<{ expiresAtMs: number }> {
|
||||||
|
const found = authenticate(raw, subject);
|
||||||
|
if (!found.ok) return found;
|
||||||
|
if (!sameBinding(found.value.session.binding, binding)) {
|
||||||
|
return { ok: false, code: "unauthorized", message: "Media binding does not match the grant." };
|
||||||
|
}
|
||||||
|
return { ok: true, value: { expiresAtMs: found.value.participant.expiresAt } };
|
||||||
|
}
|
||||||
|
|
||||||
function revokeSubject(subject: string): number {
|
function revokeSubject(subject: string): number {
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
for (const session of [...sessions.values()]) {
|
for (const session of [...sessions.values()]) {
|
||||||
@@ -409,7 +423,7 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {}
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { join, resume, signal, leave, subscribe, revalidate, revokeSubject, cleanup, sessionCount: () => sessions.size };
|
return { join, resume, signal, leave, subscribe, authorizeIce, revalidate, revokeSubject, cleanup, sessionCount: () => sessions.size };
|
||||||
}
|
}
|
||||||
|
|
||||||
function opaque(bytes: number): string { return randomBytes(bytes).toString("base64url"); }
|
function opaque(bytes: number): string { return randomBytes(bytes).toString("base64url"); }
|
||||||
|
|||||||
@@ -46,12 +46,26 @@ function parse(body: unknown) {
|
|||||||
export function registerMedia(app: FastifyInstance, services: Services): void {
|
export function registerMedia(app: FastifyInstance, services: Services): void {
|
||||||
app.post<{ Body: unknown }>("/api/v1/media/ice", { bodyLimit: 1_024 }, async (req, reply) => {
|
app.post<{ Body: unknown }>("/api/v1/media/ice", { bodyLimit: 1_024 }, async (req, reply) => {
|
||||||
const viewer = await services.auth.resolve(req);
|
const viewer = await services.auth.resolve(req);
|
||||||
if (!viewer.authenticated) {
|
if (!viewer.authenticated || viewer.subject === null) {
|
||||||
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
|
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
const parsed = parseIceConfigRequest(req.body);
|
const parsed = parseIceConfigRequest(req.body);
|
||||||
if (!parsed.ok) return reply.code(400).send({ error: "invalid", message: "ICE configuration request is invalid." });
|
if (!parsed.ok) return reply.code(400).send({ error: "invalid", message: "ICE configuration request is invalid." });
|
||||||
const issued = services.ice.issue(parsed.value, req.ip);
|
if (!(await bindingAllowed(parsed.value.binding, services))) {
|
||||||
|
return reply.code(404).send({ error: "not_found", message: "No such authored office screen." });
|
||||||
|
}
|
||||||
|
const authorization = services.media.authorizeIce(
|
||||||
|
parsed.value.credential,
|
||||||
|
parsed.value.binding,
|
||||||
|
viewer.subject,
|
||||||
|
);
|
||||||
|
if (!authorization.ok) return fail(reply, authorization);
|
||||||
|
// The provider immediately replaces these with separate keyed digests and
|
||||||
|
// retains neither the stable auth subject nor trusted proxy address.
|
||||||
|
const issued = services.ice.issue(parsed.value, {
|
||||||
|
subject: viewer.subject,
|
||||||
|
trustedIp: req.ip,
|
||||||
|
}, authorization.value.expiresAtMs);
|
||||||
if (!issued.ok) {
|
if (!issued.ok) {
|
||||||
const statusCode = issued.code === "rate-limited" ? 429 : 503;
|
const statusCode = issued.code === "rate-limited" ? 429 : 503;
|
||||||
return reply.code(statusCode)
|
return reply.code(statusCode)
|
||||||
|
|||||||
+151
-23
@@ -1,11 +1,13 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createHmac } from "node:crypto";
|
import { createHash, createHmac } from "node:crypto";
|
||||||
import { after, describe, it } from "node:test";
|
import { after, describe, it } from "node:test";
|
||||||
import { buildApp } from "../app.ts";
|
import { buildApp } from "../app.ts";
|
||||||
import { loadConfig, type IceConfig } from "../config.ts";
|
import { loadConfig, type IceConfig } from "../config.ts";
|
||||||
import { createIceCredentialProvider } from "../media/index.ts";
|
import { createIceCredentialProvider, deriveIceCredentialRateKeys } from "../media/index.ts";
|
||||||
import { parseIceConfigResponse } from "../../../src/media/iceValidation.ts";
|
import { parseIceConfigResponse } from "../../../src/media/iceValidation.ts";
|
||||||
import type { IceConfigGrant } from "../../../src/media/iceTypes.ts";
|
import type { IceConfigGrant } from "../../../src/media/iceTypes.ts";
|
||||||
|
import type { ScreenShareCredential } from "../../../src/media/signalingTypes.ts";
|
||||||
|
import type { ScreenShareBinding } from "../../../src/media/signalingTypes.ts";
|
||||||
|
|
||||||
const SHARED_SECRET = "turn-shared-secret-with-at-least-thirty-two-bytes";
|
const SHARED_SECRET = "turn-shared-secret-with-at-least-thirty-two-bytes";
|
||||||
const JWT_SECRET = "ice-route-jwt-secret-that-is-long-enough";
|
const JWT_SECRET = "ice-route-jwt-secret-that-is-long-enough";
|
||||||
@@ -20,7 +22,16 @@ const configured = (overrides: Partial<IceConfig> = {}): IceConfig => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const request = { type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1" } as const;
|
const BINDING = { officeId: "lumbridge-hq", levelId: "level-1", roomId: "lobby", screenId: "lobby-monitor" } as const;
|
||||||
|
const SECOND_BINDING = { officeId: "lumbridge-hq", levelId: "level-1", roomId: "open-floor", screenId: "open-display" } as const;
|
||||||
|
const PROVIDER_CREDENTIAL: ScreenShareCredential = {
|
||||||
|
sessionId: "opaque-session", participantId: "opaque-participant", role: "presenter", grantToken: "opaque-grant-token-value",
|
||||||
|
};
|
||||||
|
const iceRequest = (credential: ScreenShareCredential = PROVIDER_CREDENTIAL, binding: ScreenShareBinding = BINDING) => ({
|
||||||
|
type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1", binding, credential,
|
||||||
|
} as const);
|
||||||
|
const request = iceRequest();
|
||||||
|
const rateKeys = (subject = "auth-subject", trustedIp = "203.0.113.8") => ({ subject, trustedIp });
|
||||||
|
|
||||||
function bearer(subject: string) {
|
function bearer(subject: string) {
|
||||||
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||||
@@ -29,18 +40,20 @@ function bearer(subject: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("coturn REST ICE credential provider", () => {
|
describe("coturn REST ICE credential provider", () => {
|
||||||
it("uses an opaque nonce and the standard expiration:HMAC-SHA1 credential", () => {
|
it("uses an opaque quota identity and the standard expiration:HMAC-SHA1 credential", () => {
|
||||||
const provider = createIceCredentialProvider(configured(), {
|
const provider = createIceCredentialProvider(configured(), {
|
||||||
now: () => 1_700_000_000_500,
|
now: () => 1_700_000_000_500,
|
||||||
nonce: () => "opaque_random_nonce_1234",
|
|
||||||
});
|
});
|
||||||
const result = provider.issue(request, "rate-key");
|
const result = provider.issue(request, rateKeys(), 1_700_000_090_000);
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
const turn = result.value.iceServers.find((server) => "username" in server);
|
const turn = result.value.iceServers.find((server) => "username" in server);
|
||||||
assert.ok(turn && "username" in turn);
|
assert.ok(turn && "username" in turn);
|
||||||
if (!turn || !("username" in turn)) return;
|
if (!turn || !("username" in turn)) return;
|
||||||
assert.equal(turn.username, "1700000600:opaque_random_nonce_1234");
|
const suffix = createHmac("sha256", SHARED_SECRET)
|
||||||
|
.update("tera-turn-user-v1\0").update(request.credential.sessionId)
|
||||||
|
.update("\0").update(request.credential.participantId).digest("base64url");
|
||||||
|
assert.equal(turn.username, `1700000090:${suffix}`);
|
||||||
assert.equal(turn.credential, createHmac("sha1", SHARED_SECRET).update(turn.username).digest("base64"));
|
assert.equal(turn.credential, createHmac("sha1", SHARED_SECRET).update(turn.username).digest("base64"));
|
||||||
assert.equal(turn.username.includes("auth-subject"), false);
|
assert.equal(turn.username.includes("auth-subject"), false);
|
||||||
assert.equal(JSON.stringify(result.value).includes(SHARED_SECRET), false);
|
assert.equal(JSON.stringify(result.value).includes(SHARED_SECRET), false);
|
||||||
@@ -49,32 +62,79 @@ describe("coturn REST ICE credential provider", () => {
|
|||||||
|
|
||||||
it("fails closed when unconfigured and rate bounds issuance", () => {
|
it("fails closed when unconfigured and rate bounds issuance", () => {
|
||||||
const unavailable = createIceCredentialProvider(configured({ configured: false, sharedSecret: "", urls: [] }));
|
const unavailable = createIceCredentialProvider(configured({ configured: false, sharedSecret: "", urls: [] }));
|
||||||
const closed = unavailable.issue(request, "caller");
|
const closed = unavailable.issue(request, rateKeys(), Date.now() + 90_000);
|
||||||
assert.equal(closed.ok, false);
|
assert.equal(closed.ok, false);
|
||||||
if (!closed.ok) assert.equal(closed.code, "unavailable");
|
if (!closed.ok) assert.equal(closed.code, "unavailable");
|
||||||
const limited = createIceCredentialProvider(configured({ rateAttempts: 1 }), {
|
const limited = createIceCredentialProvider(configured({ rateAttempts: 1 }), {
|
||||||
now: () => 10_000, nonce: () => "opaque_random_nonce_1234",
|
now: () => 10_000,
|
||||||
});
|
});
|
||||||
assert.equal(limited.issue(request, "same-ip").ok, true);
|
assert.equal(limited.issue(request, rateKeys(), 100_000).ok, true);
|
||||||
const second = limited.issue(request, "same-ip");
|
const second = limited.issue(request, rateKeys(), 100_000);
|
||||||
assert.equal(second.ok, false);
|
assert.equal(second.ok, false);
|
||||||
if (!second.ok) assert.equal(second.code, "rate-limited");
|
if (!second.ok) assert.equal(second.code, "rate-limited");
|
||||||
assert.equal(limited.issue(request, "other-ip").ok, true);
|
assert.equal(limited.issue(request, rateKeys("other-subject", "other-ip"), 100_000).ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces independent subject and trusted-IP buckets", () => {
|
||||||
|
const bySubject = createIceCredentialProvider(configured({ rateAttempts: 1 }), { now: () => 10_000 });
|
||||||
|
assert.equal(bySubject.issue(request, rateKeys("subject-a", "ip-a"), 100_000).ok, true);
|
||||||
|
const rotatedIp = bySubject.issue(request, rateKeys("subject-a", "ip-b"), 100_000);
|
||||||
|
assert.equal(rotatedIp.ok, false, "changing address cannot evade a subject limit");
|
||||||
|
|
||||||
|
const byIp = createIceCredentialProvider(configured({ rateAttempts: 1 }), { now: () => 10_000 });
|
||||||
|
assert.equal(byIp.issue(request, rateKeys("subject-a", "shared-ip"), 100_000).ok, true);
|
||||||
|
const sharedNat = byIp.issue(request, rateKeys("subject-b", "shared-ip"), 100_000);
|
||||||
|
assert.equal(sharedNat.ok, false, "changing account cannot evade an address limit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains keyed domain-separated rate pseudonyms, not raw or plain hashes", () => {
|
||||||
|
const identity = rateKeys("stable-auth-subject", "203.0.113.42");
|
||||||
|
const first = deriveIceCredentialRateKeys(SHARED_SECRET, identity);
|
||||||
|
const second = deriveIceCredentialRateKeys(`${SHARED_SECRET}-rotated`, identity);
|
||||||
|
assert.notEqual(first.subject, identity.subject);
|
||||||
|
assert.notEqual(first.ip, identity.trustedIp);
|
||||||
|
assert.notEqual(first.subject, first.ip);
|
||||||
|
assert.notDeepEqual(first, second, "server secret rotation changes both pseudonyms");
|
||||||
|
assert.notEqual(first.subject, createHash("sha256").update(`tera-ice-subject-v1\0${identity.subject}`).digest("hex"));
|
||||||
|
assert.notEqual(first.ip, createHash("sha256").update(`tera-ice-ip-v1\0${identity.trustedIp}`).digest("hex"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the client-rate table bounded under address churn", () => {
|
it("keeps the client-rate table bounded under address churn", () => {
|
||||||
let time = 10_000;
|
let time = 10_000;
|
||||||
const provider = createIceCredentialProvider(configured({ rateAttempts: 2 }), {
|
const provider = createIceCredentialProvider(configured({ rateAttempts: 2 }), {
|
||||||
now: () => time, nonce: () => "opaque_random_nonce_1234",
|
now: () => time,
|
||||||
});
|
});
|
||||||
for (let index = 0; index < 4_096; index += 1) {
|
for (let index = 0; index < 4_096; index += 1) {
|
||||||
assert.equal(provider.issue(request, `client-${index}`).ok, true);
|
assert.equal(provider.issue(request, rateKeys(`subject-${index}`, `ip-${index}`), 100_000).ok, true);
|
||||||
}
|
}
|
||||||
const full = provider.issue(request, "client-over-cap");
|
const full = provider.issue(request, rateKeys("subject-over-cap", "ip-over-cap"), 100_000);
|
||||||
assert.equal(full.ok, false);
|
assert.equal(full.ok, false);
|
||||||
if (!full.ok) assert.equal(full.code, "rate-limited");
|
if (!full.ok) assert.equal(full.code, "rate-limited");
|
||||||
time += 60_000;
|
time += 60_000;
|
||||||
assert.equal(provider.issue(request, "client-after-window").ok, true);
|
assert.equal(provider.issue(request, rateKeys("subject-after-window", "ip-after-window"), 200_000).ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the quota suffix stable per opaque participant and distinct across participants", () => {
|
||||||
|
let time = 1_700_000_000_500;
|
||||||
|
const provider = createIceCredentialProvider(configured({ rateAttempts: 10 }), { now: () => time });
|
||||||
|
const first = provider.issue(request, rateKeys("subject-a", "ip-a"), 1_700_000_090_000);
|
||||||
|
time += 1_000;
|
||||||
|
const second = provider.issue(request, rateKeys("subject-a", "ip-a"), 1_700_000_090_000);
|
||||||
|
const another = provider.issue(
|
||||||
|
iceRequest({ ...request.credential, participantId: "another-opaque-participant" }),
|
||||||
|
rateKeys("subject-b", "ip-b"),
|
||||||
|
1_700_000_090_000,
|
||||||
|
);
|
||||||
|
assert.ok(first.ok && second.ok && another.ok);
|
||||||
|
if (!first.ok || !second.ok || !another.ok) return;
|
||||||
|
const username = (value: IceConfigGrant) => {
|
||||||
|
const turn = value.iceServers.find((server) => "username" in server);
|
||||||
|
assert.ok(turn && "username" in turn);
|
||||||
|
return turn && "username" in turn ? turn.username : "";
|
||||||
|
};
|
||||||
|
const suffix = (value: IceConfigGrant) => username(value).slice(username(value).indexOf(":") + 1);
|
||||||
|
assert.equal(suffix(first.value), suffix(second.value));
|
||||||
|
assert.notEqual(suffix(first.value), suffix(another.value));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strictly disables partial or malformed environment configuration", () => {
|
it("strictly disables partial or malformed environment configuration", () => {
|
||||||
@@ -97,7 +157,7 @@ describe("coturn REST ICE credential provider", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("authenticated ICE route", () => {
|
describe("authenticated ICE route", () => {
|
||||||
it("returns private ephemeral credentials only to authenticated POST callers", async () => {
|
it("issues private ephemeral credentials to active presenter and viewer grants", async () => {
|
||||||
const config = loadConfig({
|
const config = loadConfig({
|
||||||
TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET,
|
TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET,
|
||||||
TERA_ICE_URLS: "turn:relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET,
|
TERA_ICE_URLS: "turn:relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET,
|
||||||
@@ -106,13 +166,21 @@ describe("authenticated ICE route", () => {
|
|||||||
const app = buildApp(config); after(() => app.close());
|
const app = buildApp(config); after(() => app.close());
|
||||||
assert.equal((await app.inject({ method: "GET", url: "/api/v1/media/ice" })).statusCode, 404);
|
assert.equal((await app.inject({ method: "GET", url: "/api/v1/media/ice" })).statusCode, 404);
|
||||||
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", payload: request })).statusCode, 401);
|
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", payload: request })).statusCode, 401);
|
||||||
|
const presenterJoin = await app.inject({
|
||||||
|
method: "POST", url: "/api/v1/media/join", headers: bearer("presenter-subject"), payload: {
|
||||||
|
type: "screen-share-create-request", protocolVersion: 1, requestId: "create-1", sequence: 1,
|
||||||
|
timestampMs: 1_000, binding: BINDING, role: "presenter",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const presenter = presenterJoin.json<{ grant: { credential: ScreenShareCredential } }>();
|
||||||
const malformed = await app.inject({
|
const malformed = await app.inject({
|
||||||
method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"),
|
method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter-subject"),
|
||||||
payload: { ...request, profile: "do-not-send" },
|
payload: { ...iceRequest(presenter.grant.credential), profile: "do-not-send" },
|
||||||
});
|
});
|
||||||
assert.equal(malformed.statusCode, 400);
|
assert.equal(malformed.statusCode, 400);
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"), payload: request,
|
method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter-subject"),
|
||||||
|
payload: iceRequest(presenter.grant.credential),
|
||||||
});
|
});
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
assert.equal(response.headers["cache-control"], "private, no-store");
|
assert.equal(response.headers["cache-control"], "private, no-store");
|
||||||
@@ -120,17 +188,77 @@ describe("authenticated ICE route", () => {
|
|||||||
assert.equal(response.headers.location, undefined);
|
assert.equal(response.headers.location, undefined);
|
||||||
const body = response.json<IceConfigGrant>();
|
const body = response.json<IceConfigGrant>();
|
||||||
assert.equal(parseIceConfigResponse(body).ok, true);
|
assert.equal(parseIceConfigResponse(body).ok, true);
|
||||||
assert.equal(JSON.stringify(body).includes("auth-subject"), false);
|
assert.equal(JSON.stringify(body).includes("presenter-subject"), false);
|
||||||
|
assert.ok(body.expiresAtMs <= Date.now() + 90_000, "TURN credential cannot outlive the signaling grant");
|
||||||
|
|
||||||
|
const viewerJoin = await app.inject({
|
||||||
|
method: "POST", url: "/api/v1/media/join", headers: bearer("viewer-subject"), payload: {
|
||||||
|
type: "screen-share-join-request", protocolVersion: 1, requestId: "join-1", sequence: 1,
|
||||||
|
timestampMs: 1_001, binding: BINDING, role: "viewer", viewerOptIn: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const viewer = viewerJoin.json<{ grant: { credential: ScreenShareCredential } }>();
|
||||||
|
const viewerIce = await app.inject({
|
||||||
|
method: "POST", url: "/api/v1/media/ice", headers: bearer("viewer-subject"),
|
||||||
|
payload: iceRequest(viewer.grant.credential),
|
||||||
|
});
|
||||||
|
assert.equal(viewerIce.statusCode, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing, stolen, wrong-binding, stopped, and revoked signaling grants", async () => {
|
||||||
|
const config = loadConfig({
|
||||||
|
TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET,
|
||||||
|
TERA_ICE_URLS: "turn:relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET,
|
||||||
|
});
|
||||||
|
config.logLevel = "silent";
|
||||||
|
const app = buildApp(config); after(() => app.close());
|
||||||
|
const createPayload = {
|
||||||
|
type: "screen-share-create-request", protocolVersion: 1, requestId: "create-negative", sequence: 1,
|
||||||
|
timestampMs: 1_000, binding: BINDING, role: "presenter",
|
||||||
|
};
|
||||||
|
const created = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("presenter"), payload: createPayload });
|
||||||
|
const presenter = created.json<{ grant: { credential: ScreenShareCredential } }>().grant.credential;
|
||||||
|
const missing = { ...iceRequest(presenter) } as Record<string, unknown>;
|
||||||
|
delete missing.credential;
|
||||||
|
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter"), payload: missing })).statusCode, 400);
|
||||||
|
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("thief"), payload: iceRequest(presenter) })).statusCode, 401);
|
||||||
|
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter"), payload: iceRequest(presenter, SECOND_BINDING) })).statusCode, 401);
|
||||||
|
|
||||||
|
const joined = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("viewer"), payload: {
|
||||||
|
type: "screen-share-join-request", protocolVersion: 1, requestId: "join-negative", sequence: 1,
|
||||||
|
timestampMs: 1_001, binding: BINDING, role: "viewer", viewerOptIn: true,
|
||||||
|
} });
|
||||||
|
const viewer = joined.json<{ grant: { credential: ScreenShareCredential } }>().grant.credential;
|
||||||
|
const revoked = await app.inject({ method: "POST", url: "/api/v1/media/leave", headers: bearer("viewer"), payload: {
|
||||||
|
type: "screen-share-revoke-request", protocolVersion: 1, requestId: "viewer-left", sequence: 2,
|
||||||
|
timestampMs: 1_002, binding: BINDING, credential: viewer, scope: "participant",
|
||||||
|
targetParticipantId: viewer.participantId, reason: "viewer-left",
|
||||||
|
} });
|
||||||
|
assert.equal(revoked.statusCode, 204);
|
||||||
|
const revokedIce = await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("viewer"), payload: iceRequest(viewer) });
|
||||||
|
assert.equal(revokedIce.statusCode, 401, revokedIce.body);
|
||||||
|
|
||||||
|
const stopped = await app.inject({ method: "POST", url: "/api/v1/media/leave", headers: bearer("presenter"), payload: {
|
||||||
|
type: "screen-share-stop-request", protocolVersion: 1, requestId: "presenter-stop", sequence: 2,
|
||||||
|
timestampMs: 1_003, binding: BINDING, credential: presenter, reason: "presenter-stopped",
|
||||||
|
} });
|
||||||
|
assert.equal(stopped.statusCode, 204);
|
||||||
|
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter"), payload: iceRequest(presenter) })).statusCode, 401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns typed unavailable without emitting credentials when TURN is absent", async () => {
|
it("returns typed unavailable without emitting credentials when TURN is absent", async () => {
|
||||||
const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET });
|
const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET });
|
||||||
config.logLevel = "silent";
|
config.logLevel = "silent";
|
||||||
const app = buildApp(config); after(() => app.close());
|
const app = buildApp(config); after(() => app.close());
|
||||||
|
const created = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("auth-subject"), payload: {
|
||||||
|
type: "screen-share-create-request", protocolVersion: 1, requestId: "create-unavailable", sequence: 1,
|
||||||
|
timestampMs: 1_000, binding: BINDING, role: "presenter",
|
||||||
|
} });
|
||||||
|
const credential = created.json<{ grant: { credential: ScreenShareCredential } }>().grant.credential;
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"), payload: request,
|
method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"), payload: iceRequest(credential),
|
||||||
});
|
});
|
||||||
assert.equal(response.statusCode, 503);
|
assert.equal(response.statusCode, 503, response.body);
|
||||||
assert.equal(response.json().type, "ice-config-unavailable");
|
assert.equal(response.json().type, "ice-config-unavailable");
|
||||||
assert.equal(JSON.stringify(response.json()).includes("credential"), false);
|
assert.equal(JSON.stringify(response.json()).includes("credential"), false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,6 +76,23 @@ describe("office media signaling service", () => {
|
|||||||
assert.equal(service.sessionCount(), 0);
|
assert.equal(service.sessionCount(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("authorizes ICE only for a live grant owner on its exact screen binding", () => {
|
||||||
|
let now = 10_000;
|
||||||
|
const service = createMediaSignalService({ now: () => now, grantTtlMs: 100 });
|
||||||
|
const presenter = service.join(create(), "presenter-subject");
|
||||||
|
assert.equal(presenter.ok, true);
|
||||||
|
if (!presenter.ok || presenter.value.type !== "screen-share-create-grant") return;
|
||||||
|
const credential = presenter.value.grant.credential;
|
||||||
|
assert.deepEqual(service.authorizeIce(credential, BINDING, "presenter-subject"), {
|
||||||
|
ok: true, value: { expiresAtMs: 10_100 },
|
||||||
|
});
|
||||||
|
assert.equal(service.authorizeIce(credential, BINDING, "wrong-subject").ok, false);
|
||||||
|
assert.equal(service.authorizeIce(credential, { ...BINDING, screenId: "another-screen" }, "presenter-subject").ok, false);
|
||||||
|
now = 10_101;
|
||||||
|
assert.equal(service.authorizeIce(credential, BINDING, "presenter-subject").ok, false,
|
||||||
|
"expired signaling grants cannot mint fresh TURN credentials");
|
||||||
|
});
|
||||||
|
|
||||||
it("delivers a queued offer after resume with a sequence newer than the grant", () => {
|
it("delivers a queued offer after resume with a sequence newer than the grant", () => {
|
||||||
const service = createMediaSignalService();
|
const service = createMediaSignalService();
|
||||||
const { presenter, viewer } = grants(service);
|
const { presenter, viewer } = grants(service);
|
||||||
|
|||||||
+2
-16
@@ -2365,17 +2365,10 @@ async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise<void>
|
|||||||
remotePanelStatus(surface.screenId, "connecting");
|
remotePanelStatus(surface.screenId, "connecting");
|
||||||
showDetail(`Connecting to ${surface.screenId}…`);
|
showDetail(`Connecting to ${surface.screenId}…`);
|
||||||
try {
|
try {
|
||||||
const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([
|
const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts");
|
||||||
import("./media/remoteMedia.ts"),
|
|
||||||
import("./media/iceClient.ts"),
|
|
||||||
]);
|
|
||||||
const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false;
|
const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false;
|
||||||
if (operation !== remoteMediaOperation || access.subject === null || !inside || !office ||
|
if (operation !== remoteMediaOperation || access.subject === null || !inside || !office ||
|
||||||
office.depth !== "full" || officeId !== surface.officeId || !optedIn) return;
|
office.depth !== "full" || officeId !== surface.officeId || !optedIn) return;
|
||||||
const ice = await fetchEphemeralIceConfiguration({ authenticatedFetch: authFetch });
|
|
||||||
if (operation !== remoteMediaOperation || access.subject === null || !inside || !office ||
|
|
||||||
office.depth !== "full" || officeId !== surface.officeId ||
|
|
||||||
!(officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false)) return;
|
|
||||||
const video = document.createElement("video");
|
const video = document.createElement("video");
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
video.playsInline = true;
|
video.playsInline = true;
|
||||||
@@ -2388,7 +2381,6 @@ async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise<void>
|
|||||||
role: "viewer",
|
role: "viewer",
|
||||||
binding: screenBinding(surface),
|
binding: screenBinding(surface),
|
||||||
authenticatedFetch: authFetch,
|
authenticatedFetch: authFetch,
|
||||||
peerConnectionConfiguration: ice.configuration,
|
|
||||||
onStateChange: (state) => { if (viewing) syncRemoteViewerState(viewing, state); },
|
onStateChange: (state) => { if (viewing) syncRemoteViewerState(viewing, state); },
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (remoteViewedScreen !== viewing) return;
|
if (remoteViewedScreen !== viewing) return;
|
||||||
@@ -2423,18 +2415,12 @@ async function startRemotePresenter(
|
|||||||
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
|
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
|
||||||
remotePanelStatus(surface.screenId, "connecting");
|
remotePanelStatus(surface.screenId, "connecting");
|
||||||
try {
|
try {
|
||||||
const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([
|
const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts");
|
||||||
import("./media/remoteMedia.ts"),
|
|
||||||
import("./media/iceClient.ts"),
|
|
||||||
]);
|
|
||||||
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
|
|
||||||
const ice = await fetchEphemeralIceConfiguration({ authenticatedFetch: authFetch });
|
|
||||||
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
|
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
|
||||||
const remote = createRemoteOfficeMedia({
|
const remote = createRemoteOfficeMedia({
|
||||||
role: "presenter",
|
role: "presenter",
|
||||||
binding: screenBinding(surface),
|
binding: screenBinding(surface),
|
||||||
authenticatedFetch: authFetch,
|
authenticatedFetch: authFetch,
|
||||||
peerConnectionConfiguration: ice.configuration,
|
|
||||||
onStateChange(state) {
|
onStateChange(state) {
|
||||||
if (sharedScreen !== shared) return;
|
if (sharedScreen !== shared) return;
|
||||||
if (state.status === "live") remotePanelStatus(surface.screenId, "live");
|
if (state.status === "live") remotePanelStatus(surface.screenId, "live");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ICE_CONFIG_PROTOCOL_VERSION } from "./iceTypes.ts";
|
import { ICE_CONFIG_PROTOCOL_VERSION } from "./iceTypes.ts";
|
||||||
import { isIceConfigGrantActive, parseIceConfigResponse } from "./iceValidation.ts";
|
import { isIceConfigGrantActive, parseIceConfigResponse } from "./iceValidation.ts";
|
||||||
|
import type { ScreenShareBinding, ScreenShareCredential } from "./signalingTypes.ts";
|
||||||
|
|
||||||
export interface EphemeralIceConfiguration {
|
export interface EphemeralIceConfiguration {
|
||||||
readonly configuration: RTCConfiguration;
|
readonly configuration: RTCConfiguration;
|
||||||
@@ -8,6 +9,9 @@ export interface EphemeralIceConfiguration {
|
|||||||
|
|
||||||
export interface FetchEphemeralIceConfigurationOptions {
|
export interface FetchEphemeralIceConfigurationOptions {
|
||||||
authenticatedFetch: typeof globalThis.fetch;
|
authenticatedFetch: typeof globalThis.fetch;
|
||||||
|
/** Exact server-authored screen and active signaling capability. */
|
||||||
|
binding: ScreenShareBinding;
|
||||||
|
credential: ScreenShareCredential;
|
||||||
endpoint?: string;
|
endpoint?: string;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
requestId?: () => string;
|
requestId?: () => string;
|
||||||
@@ -49,6 +53,8 @@ export async function fetchEphemeralIceConfiguration(
|
|||||||
type: "ice-config-request",
|
type: "ice-config-request",
|
||||||
protocolVersion: ICE_CONFIG_PROTOCOL_VERSION,
|
protocolVersion: ICE_CONFIG_PROTOCOL_VERSION,
|
||||||
requestId: id,
|
requestId: id,
|
||||||
|
binding: { ...options.binding },
|
||||||
|
credential: { ...options.credential },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
/** JSON-only contract for fetching ephemeral WebRTC ICE configuration. */
|
/** JSON-only contract for fetching ephemeral WebRTC ICE configuration. */
|
||||||
|
|
||||||
|
import type { ScreenShareBinding, ScreenShareCredential } from "./signalingTypes.ts";
|
||||||
|
|
||||||
export const ICE_CONFIG_PROTOCOL_VERSION = 1 as const;
|
export const ICE_CONFIG_PROTOCOL_VERSION = 1 as const;
|
||||||
|
|
||||||
export interface IceConfigRequest {
|
export interface IceConfigRequest {
|
||||||
type: "ice-config-request";
|
type: "ice-config-request";
|
||||||
protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION;
|
protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
|
binding: ScreenShareBinding;
|
||||||
|
credential: ScreenShareCredential;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StunIceServer {
|
export interface StunIceServer {
|
||||||
|
|||||||
@@ -35,12 +35,27 @@ export function isSafeIceUrl(value: unknown): value is string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseIceConfigRequest(value: unknown): IceConfigValidationResult<IceConfigRequest> {
|
export function parseIceConfigRequest(value: unknown): IceConfigValidationResult<IceConfigRequest> {
|
||||||
if (!exact(value, ["type", "protocolVersion", "requestId"]) ||
|
if (!exact(value, ["type", "protocolVersion", "requestId", "binding", "credential"]) ||
|
||||||
value.type !== "ice-config-request" || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION ||
|
value.type !== "ice-config-request" || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION ||
|
||||||
!identifier(value.requestId)) return failure("invalid request");
|
!identifier(value.requestId) || !screenBinding(value.binding) ||
|
||||||
|
!screenCredential(value.credential)) return failure("invalid request");
|
||||||
return success(value as unknown as IceConfigRequest);
|
return success(value as unknown as IceConfigRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function screenBinding(value: unknown): boolean {
|
||||||
|
return exact(value, ["officeId", "levelId", "roomId", "screenId"]) &&
|
||||||
|
identifier(value.officeId) && identifier(value.levelId) &&
|
||||||
|
(value.roomId === null || identifier(value.roomId)) && identifier(value.screenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenCredential(value: unknown): boolean {
|
||||||
|
return exact(value, ["sessionId", "participantId", "role", "grantToken"]) &&
|
||||||
|
identifier(value.sessionId) && identifier(value.participantId) &&
|
||||||
|
(value.role === "presenter" || value.role === "viewer") &&
|
||||||
|
typeof value.grantToken === "string" && value.grantToken.length >= 16 &&
|
||||||
|
value.grantToken.length <= 512;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseIceConfigResponse(value: unknown): IceConfigValidationResult<IceConfigResponse> {
|
export function parseIceConfigResponse(value: unknown): IceConfigValidationResult<IceConfigResponse> {
|
||||||
if (!plain(value) || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION || !identifier(value.requestId)) {
|
if (!plain(value) || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION || !identifier(value.requestId)) {
|
||||||
return failure("invalid response envelope");
|
return failure("invalid response envelope");
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
type ScreenShareStreamCursor,
|
type ScreenShareStreamCursor,
|
||||||
} from "./signalingTypes.ts";
|
} from "./signalingTypes.ts";
|
||||||
import type { MediaAuthorizationDecision } from "./types.ts";
|
import type { MediaAuthorizationDecision } from "./types.ts";
|
||||||
|
import {
|
||||||
|
fetchEphemeralIceConfiguration,
|
||||||
|
type EphemeralIceConfiguration,
|
||||||
|
} from "./iceClient.ts";
|
||||||
|
|
||||||
export type RemoteMediaRole = ScreenShareRole;
|
export type RemoteMediaRole = ScreenShareRole;
|
||||||
export type RemoteMediaStatus = "idle" | "connecting" | "live" | "reconnecting" | "stopped" | "revoked" | "disposed";
|
export type RemoteMediaStatus = "idle" | "connecting" | "live" | "reconnecting" | "stopped" | "revoked" | "disposed";
|
||||||
@@ -33,7 +37,7 @@ export interface RemoteMediaState {
|
|||||||
reconnectAttempt: number;
|
reconnectAttempt: number;
|
||||||
hasRemoteVideo: boolean;
|
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 {
|
export interface RemoteMediaTimers {
|
||||||
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
||||||
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
||||||
@@ -45,7 +49,11 @@ export interface RemoteOfficeMediaOptions {
|
|||||||
authenticatedFetch?: typeof globalThis.fetch;
|
authenticatedFetch?: typeof globalThis.fetch;
|
||||||
fetch?: typeof globalThis.fetch;
|
fetch?: typeof globalThis.fetch;
|
||||||
peerConnectionFactory?: (configuration?: RTCConfiguration) => RTCPeerConnection;
|
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>;
|
endpoints?: Partial<RemoteMediaEndpoints>;
|
||||||
timers?: RemoteMediaTimers;
|
timers?: RemoteMediaTimers;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
@@ -95,6 +103,7 @@ const ENDPOINTS: RemoteMediaEndpoints = {
|
|||||||
signal: "/api/v1/media/signal",
|
signal: "/api/v1/media/signal",
|
||||||
events: "/api/v1/media/events",
|
events: "/api/v1/media/events",
|
||||||
leave: "/api/v1/media/leave",
|
leave: "/api/v1/media/leave",
|
||||||
|
ice: "/api/v1/media/ice",
|
||||||
};
|
};
|
||||||
const TIMERS: RemoteMediaTimers = {
|
const TIMERS: RemoteMediaTimers = {
|
||||||
setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay),
|
setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay),
|
||||||
@@ -144,6 +153,14 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
const now = options.now ?? Date.now;
|
const now = options.now ?? Date.now;
|
||||||
const reconnectBase = options.reconnectBaseMs ?? 500;
|
const reconnectBase = options.reconnectBaseMs ?? 500;
|
||||||
const reconnectMaximum = options.reconnectMaximumMs ?? 15_000;
|
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) {
|
if (!(reconnectBase > 0) || !Number.isFinite(reconnectBase) || reconnectMaximum < reconnectBase) {
|
||||||
throw new RangeError("remote media: invalid reconnect bounds");
|
throw new RangeError("remote media: invalid reconnect bounds");
|
||||||
}
|
}
|
||||||
@@ -157,6 +174,8 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
let presenter: PresenterStart | null = null;
|
let presenter: PresenterStart | null = null;
|
||||||
let viewer: ViewerStart | null = null;
|
let viewer: ViewerStart | null = null;
|
||||||
let receiverStream: MediaStream | null = null;
|
let receiverStream: MediaStream | null = null;
|
||||||
|
let iceConfiguration: EphemeralIceConfiguration | null = null;
|
||||||
|
let iceAuthorization: ScreenShareCredential | null = null;
|
||||||
let eventAbort: AbortController | null = null;
|
let eventAbort: AbortController | null = null;
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let renewalTimer: ReturnType<typeof setTimeout> | null = null;
|
let renewalTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -225,6 +244,8 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
renewalTimer = null;
|
renewalTimer = null;
|
||||||
for (const id of [...peers.keys()]) closePeer(id);
|
for (const id of [...peers.keys()]) closePeer(id);
|
||||||
releaseReceiver();
|
releaseReceiver();
|
||||||
|
iceConfiguration = null;
|
||||||
|
iceAuthorization = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPeerMesh(): void {
|
function resetPeerMesh(): void {
|
||||||
@@ -232,6 +253,33 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
releaseReceiver();
|
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> {
|
async function sendSignal(targetParticipantId: string, signal: ScreenShareSignalPayload): Promise<void> {
|
||||||
if (!grant) throw new Error("remote media: signaling grant is missing");
|
if (!grant) throw new Error("remote media: signaling grant is missing");
|
||||||
const request: ScreenShareClientMessage = {
|
const request: ScreenShareClientMessage = {
|
||||||
@@ -250,7 +298,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
const prior = peers.get(participant.participantId);
|
const prior = peers.get(participant.participantId);
|
||||||
if (prior) return prior;
|
if (prior) return prior;
|
||||||
const activeGeneration = generation;
|
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: [] };
|
const slot: PeerSlot = { id: participant.participantId, connection, pendingIce: [] };
|
||||||
peers.set(slot.id, slot);
|
peers.set(slot.id, slot);
|
||||||
connection.onicecandidate = (event) => {
|
connection.onicecandidate = (event) => {
|
||||||
@@ -305,6 +356,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
const ids = new Set(allowed.map((item) => item.participantId));
|
const ids = new Set(allowed.map((item) => item.participantId));
|
||||||
for (const id of [...peers.keys()]) if (!ids.has(id)) closePeer(id);
|
for (const id of [...peers.keys()]) if (!ids.has(id)) closePeer(id);
|
||||||
participants = allowed;
|
participants = allowed;
|
||||||
|
if (allowed.some((participant) => !peers.has(participant.participantId))) await ensureIceConfiguration();
|
||||||
if (options.role === "presenter") {
|
if (options.role === "presenter") {
|
||||||
for (const participant of allowed) if (!peers.has(participant.participantId)) await offer(participant);
|
for (const participant of allowed) if (!peers.has(participant.participantId)) await offer(participant);
|
||||||
} else {
|
} else {
|
||||||
@@ -343,6 +395,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
if (message.targetParticipantId !== grant.credential.participantId) return;
|
if (message.targetParticipantId !== grant.credential.participantId) return;
|
||||||
const from = participants.find((item) => item.participantId === message.fromParticipantId);
|
const from = participants.find((item) => item.participantId === message.fromParticipantId);
|
||||||
if (!from) return;
|
if (!from) return;
|
||||||
|
if (!peers.has(from.participantId)) await ensureIceConfiguration();
|
||||||
const connection = configurePeer(from).connection;
|
const connection = configurePeer(from).connection;
|
||||||
if (message.signal.kind === "ice-complete") {
|
if (message.signal.kind === "ice-complete") {
|
||||||
if (connection.remoteDescription) await connection.addIceCandidate(null);
|
if (connection.remoteDescription) await connection.addIceCandidate(null);
|
||||||
@@ -508,6 +561,9 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
grant = response.grant;
|
grant = response.grant;
|
||||||
clientSequence = response.nextClientSequence;
|
clientSequence = response.nextClientSequence;
|
||||||
cursor = { sessionId: response.grant.credential.sessionId, sequence: response.sequence, timestampMs: response.timestampMs };
|
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);
|
await reconcile(response.participants);
|
||||||
scheduleRenewal();
|
scheduleRenewal();
|
||||||
if (role === "presenter" && peers.size === 0) setStatus("live");
|
if (role === "presenter" && peers.size === 0) setStatus("live");
|
||||||
@@ -532,8 +588,11 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
await join("presenter", generation);
|
await join("presenter", generation);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (status === "connecting") {
|
if (status === "connecting") {
|
||||||
closeTransport();
|
// A join can succeed while the grant-bound TURN exchange fails. End
|
||||||
grant = null;
|
// 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;
|
presenter = null;
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
}
|
}
|
||||||
@@ -564,8 +623,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
await join("viewer", generation);
|
await join("viewer", generation);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (status === "connecting") {
|
if (status === "connecting") {
|
||||||
closeTransport();
|
try { await leave(); } catch { closeTransport(); grant = null; participants = []; }
|
||||||
grant = null;
|
|
||||||
viewer = null;
|
viewer = null;
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ import {
|
|||||||
IceConfigurationUnavailableError,
|
IceConfigurationUnavailableError,
|
||||||
} from "../media/iceClient.ts";
|
} from "../media/iceClient.ts";
|
||||||
|
|
||||||
|
const authorization = {
|
||||||
|
binding: { officeId: "lumbridge-hq", levelId: "level-1", roomId: "commons", screenId: "wall-display" },
|
||||||
|
credential: {
|
||||||
|
sessionId: "share-session", participantId: "viewer-opaque", role: "viewer" as const,
|
||||||
|
grantToken: "private-viewer-grant",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
describe("ephemeral ICE configuration client", () => {
|
describe("ephemeral ICE configuration client", () => {
|
||||||
it("posts the exact authenticated request and returns an in-memory RTC configuration", async () => {
|
it("posts the exact authenticated request and returns an in-memory RTC configuration", async () => {
|
||||||
let input: RequestInfo | URL | undefined;
|
let input: RequestInfo | URL | undefined;
|
||||||
@@ -32,6 +40,7 @@ describe("ephemeral ICE configuration client", () => {
|
|||||||
},
|
},
|
||||||
now: () => 200,
|
now: () => 200,
|
||||||
requestId: () => "ice-test-1",
|
requestId: () => "ice-test-1",
|
||||||
|
...authorization,
|
||||||
});
|
});
|
||||||
assert.equal(input, "/api/v1/media/ice");
|
assert.equal(input, "/api/v1/media/ice");
|
||||||
assert.equal(init?.method, "POST");
|
assert.equal(init?.method, "POST");
|
||||||
@@ -41,6 +50,7 @@ describe("ephemeral ICE configuration client", () => {
|
|||||||
type: "ice-config-request",
|
type: "ice-config-request",
|
||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
requestId: "ice-test-1",
|
requestId: "ice-test-1",
|
||||||
|
...authorization,
|
||||||
});
|
});
|
||||||
assert.deepEqual(result.configuration.iceServers, [
|
assert.deepEqual(result.configuration.iceServers, [
|
||||||
{ urls: ["stun:relay.example.test:3478"] },
|
{ urls: ["stun:relay.example.test:3478"] },
|
||||||
@@ -59,6 +69,7 @@ describe("ephemeral ICE configuration client", () => {
|
|||||||
authenticatedFetch: async () => Response.json(body),
|
authenticatedFetch: async () => Response.json(body),
|
||||||
now: () => 200,
|
now: () => 200,
|
||||||
requestId: () => "ice-test-1",
|
requestId: () => "ice-test-1",
|
||||||
|
...authorization,
|
||||||
});
|
});
|
||||||
await assert.rejects(run({
|
await assert.rejects(run({
|
||||||
type: "ice-config-unavailable",
|
type: "ice-config-unavailable",
|
||||||
@@ -98,6 +109,7 @@ describe("ephemeral ICE configuration client", () => {
|
|||||||
retryAfterMs: 12_345,
|
retryAfterMs: 12_345,
|
||||||
}, { status }),
|
}, { status }),
|
||||||
requestId: () => "ice-backoff-1",
|
requestId: () => "ice-backoff-1",
|
||||||
|
...authorization,
|
||||||
}), (error: unknown) => {
|
}), (error: unknown) => {
|
||||||
assert.ok(error instanceof IceConfigurationUnavailableError);
|
assert.ok(error instanceof IceConfigurationUnavailableError);
|
||||||
assert.equal(error.retryAfterMs, 12_345);
|
assert.equal(error.retryAfterMs, 12_345);
|
||||||
@@ -114,11 +126,13 @@ describe("ephemeral ICE configuration client", () => {
|
|||||||
retryAfterMs: 12_345,
|
retryAfterMs: 12_345,
|
||||||
}, { status: 503 }),
|
}, { status: 503 }),
|
||||||
requestId: () => "ice-backoff-1",
|
requestId: () => "ice-backoff-1",
|
||||||
|
...authorization,
|
||||||
}), /did not match/);
|
}), /did not match/);
|
||||||
|
|
||||||
await assert.rejects(fetchEphemeralIceConfiguration({
|
await assert.rejects(fetchEphemeralIceConfiguration({
|
||||||
authenticatedFetch: async () => Response.json({ retryAfterMs: 1 }, { status: 503 }),
|
authenticatedFetch: async () => Response.json({ retryAfterMs: 1 }, { status: 503 }),
|
||||||
requestId: () => "ice-backoff-1",
|
requestId: () => "ice-backoff-1",
|
||||||
|
...authorization,
|
||||||
}), /failed \(503\)/);
|
}), /failed \(503\)/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ import {
|
|||||||
type IceConfigGrant,
|
type IceConfigGrant,
|
||||||
} from "../media/index.ts";
|
} from "../media/index.ts";
|
||||||
|
|
||||||
|
const request = () => ({
|
||||||
|
type: "ice-config-request",
|
||||||
|
protocolVersion: 1,
|
||||||
|
requestId: "ice-request-1",
|
||||||
|
binding: { officeId: "lumbridge-hq", levelId: "level-1", roomId: "lobby", screenId: "lobby-monitor" },
|
||||||
|
credential: {
|
||||||
|
sessionId: "opaque-session", participantId: "opaque-participant", role: "presenter", grantToken: "opaque-grant-token-value",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const grant = (): IceConfigGrant => ({
|
const grant = (): IceConfigGrant => ({
|
||||||
type: "ice-config-grant",
|
type: "ice-config-grant",
|
||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
@@ -27,9 +37,7 @@ const grant = (): IceConfigGrant => ({
|
|||||||
|
|
||||||
describe("ICE configuration wire contract", () => {
|
describe("ICE configuration wire contract", () => {
|
||||||
it("accepts exact requests and bounded ephemeral grants", () => {
|
it("accepts exact requests and bounded ephemeral grants", () => {
|
||||||
assert.equal(parseIceConfigRequest({
|
assert.equal(parseIceConfigRequest(request()).ok, true);
|
||||||
type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1",
|
|
||||||
}).ok, true);
|
|
||||||
assert.equal(parseIceConfigResponse(JSON.parse(JSON.stringify(grant()))).ok, true);
|
assert.equal(parseIceConfigResponse(JSON.parse(JSON.stringify(grant()))).ok, true);
|
||||||
assert.equal(isIceConfigGrantActive(grant(), 300_000), true);
|
assert.equal(isIceConfigGrantActive(grant(), 300_000), true);
|
||||||
assert.equal(isIceConfigGrantActive(grant(), 601_000), false);
|
assert.equal(isIceConfigGrantActive(grant(), 601_000), false);
|
||||||
@@ -53,9 +61,10 @@ describe("ICE configuration wire contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects extra keys, credential tricks, missing TURN, and excessive lifetime", () => {
|
it("rejects extra keys, credential tricks, missing TURN, and excessive lifetime", () => {
|
||||||
assert.equal(parseIceConfigRequest({
|
assert.equal(parseIceConfigRequest({ ...request(), subject: "stable-user" }).ok, false);
|
||||||
type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1", subject: "stable-user",
|
const { credential: _credential, ...withoutCredential } = request();
|
||||||
}).ok, false);
|
assert.equal(parseIceConfigRequest(withoutCredential).ok, false);
|
||||||
|
assert.equal(parseIceConfigRequest({ ...request(), binding: { ...request().binding, screenId: "bad id" } }).ok, false);
|
||||||
assert.equal(parseIceConfigResponse({ ...grant(), identity: { profile: "karti" } }).ok, false);
|
assert.equal(parseIceConfigResponse({ ...grant(), identity: { profile: "karti" } }).ok, false);
|
||||||
assert.equal(parseIceConfigResponse({ ...grant(), iceServers: [{ urls: ["stun:relay.example.test:3478"] }] }).ok, false);
|
assert.equal(parseIceConfigResponse({ ...grant(), iceServers: [{ urls: ["stun:relay.example.test:3478"] }] }).ok, false);
|
||||||
assert.equal(parseIceConfigResponse({ ...grant(), expiresAtMs: 3_601_001 }).ok, false);
|
assert.equal(parseIceConfigResponse({ ...grant(), expiresAtMs: 3_601_001 }).ok, false);
|
||||||
|
|||||||
@@ -124,6 +124,28 @@ function grant(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function iceGrant(requestId: string) {
|
||||||
|
return {
|
||||||
|
type: "ice-config-grant",
|
||||||
|
protocolVersion: 1,
|
||||||
|
requestId,
|
||||||
|
issuedAtMs: NOW - 1_000,
|
||||||
|
expiresAtMs: NOW + 50_000,
|
||||||
|
iceServers: [{
|
||||||
|
urls: ["turns:relay.example.test:5349"],
|
||||||
|
username: "temporary-user-01",
|
||||||
|
credential: "temporary-password-01",
|
||||||
|
credentialType: "password",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function iceResponse(input: RequestInfo | URL, body: Record<string, unknown>): Response | null {
|
||||||
|
return String(input).endsWith("/ice")
|
||||||
|
? Response.json(iceGrant(String(body.requestId)))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
function sse(messages: readonly unknown[]): Response {
|
function sse(messages: readonly unknown[]): Response {
|
||||||
const bytes = new TextEncoder().encode(messages.map((message) => `data: ${JSON.stringify(message)}\n\n`).join(""));
|
const bytes = new TextEncoder().encode(messages.map((message) => `data: ${JSON.stringify(message)}\n\n`).join(""));
|
||||||
return new Response(new ReadableStream<Uint8Array>({
|
return new Response(new ReadableStream<Uint8Array>({
|
||||||
@@ -165,6 +187,8 @@ describe("remote office media presenter", () => {
|
|||||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||||
calls.push({ url: String(input), init, body });
|
calls.push({ url: String(input), init, body });
|
||||||
|
const ice = iceResponse(input, body);
|
||||||
|
if (ice) return ice;
|
||||||
if (String(input).endsWith("/join")) {
|
if (String(input).endsWith("/join")) {
|
||||||
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", []), { status: 201 });
|
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", []), { status: 201 });
|
||||||
}
|
}
|
||||||
@@ -189,6 +213,16 @@ describe("remote office media presenter", () => {
|
|||||||
assert.equal(video.autoplay, false);
|
assert.equal(video.autoplay, false);
|
||||||
assert.equal(video.muted, true);
|
assert.equal(video.muted, true);
|
||||||
const signal = calls.find((call) => call.url.endsWith("/signal"));
|
const signal = calls.find((call) => call.url.endsWith("/signal"));
|
||||||
|
const joinIndex = calls.findIndex((call) => call.url.endsWith("/join"));
|
||||||
|
const iceIndex = calls.findIndex((call) => call.url.endsWith("/ice"));
|
||||||
|
assert.ok(joinIndex >= 0 && iceIndex > joinIndex, "signaling authorization precedes TURN exchange");
|
||||||
|
assert.deepEqual(calls[iceIndex]?.body, {
|
||||||
|
type: "ice-config-request",
|
||||||
|
protocolVersion: 1,
|
||||||
|
requestId: calls[iceIndex]?.body.requestId,
|
||||||
|
binding: BINDING,
|
||||||
|
credential: access("presenter", "presenter-opaque").credential,
|
||||||
|
});
|
||||||
assert.equal((signal?.body.signal as { kind?: string }).kind, "sdp");
|
assert.equal((signal?.body.signal as { kind?: string }).kind, "sdp");
|
||||||
assert.equal(signal?.init.credentials, "same-origin");
|
assert.equal(signal?.init.credentials, "same-origin");
|
||||||
assert.equal(calls.some((call) => call.url.includes("private-presenter-grant")), false);
|
assert.equal(calls.some((call) => call.url.includes("private-presenter-grant")), false);
|
||||||
@@ -201,11 +235,23 @@ describe("remote office media presenter", () => {
|
|||||||
it("tears down failed peers and rebuilds them from a resumed participant snapshot", async () => {
|
it("tears down failed peers and rebuilds them from a resumed participant snapshot", async () => {
|
||||||
const timers = new FakeTimers();
|
const timers = new FakeTimers();
|
||||||
const peers: FakePeer[] = [];
|
const peers: FakePeer[] = [];
|
||||||
|
const iceTokens: string[] = [];
|
||||||
|
let eventCalls = 0;
|
||||||
const participant = { participantId: "viewer-opaque", role: "viewer" as const };
|
const participant = { participantId: "viewer-opaque", role: "viewer" as const };
|
||||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||||
|
const ice = iceResponse(input, body);
|
||||||
|
if (ice) {
|
||||||
|
iceTokens.push((body.credential as { grantToken: string }).grantToken);
|
||||||
|
return ice;
|
||||||
|
}
|
||||||
if (String(input).endsWith("/join")) return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", [participant]), { status: 201 });
|
if (String(input).endsWith("/join")) return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", [participant]), { status: 201 });
|
||||||
if (String(input).endsWith("/events")) return sse([grant("screen-share-resume-grant", String(body.requestId), "presenter", [participant])]);
|
if (String(input).endsWith("/events")) {
|
||||||
|
eventCalls += 1;
|
||||||
|
const resumed = grant("screen-share-resume-grant", String(body.requestId), "presenter", [participant]);
|
||||||
|
resumed.grant.credential.grantToken = `rotated-reconnect-${eventCalls}`;
|
||||||
|
return sse([resumed]);
|
||||||
|
}
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
};
|
};
|
||||||
const media = createRemoteOfficeMedia({
|
const media = createRemoteOfficeMedia({
|
||||||
@@ -214,6 +260,8 @@ describe("remote office media presenter", () => {
|
|||||||
});
|
});
|
||||||
const track = new FakeTrack();
|
const track = new FakeTrack();
|
||||||
await media.startPresenter({ consent: { authorized: true, optedIn: true }, stream: new FakeStream([track]) as unknown as MediaStream, video: new FakeVideo() as unknown as HTMLVideoElement });
|
await media.startPresenter({ consent: { authorized: true, optedIn: true }, stream: new FakeStream([track]) as unknown as MediaStream, video: new FakeVideo() as unknown as HTMLVideoElement });
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(iceTokens, ["private-presenter-grant"], "connected peer keeps its active TURN allocation across token rotation");
|
||||||
peers[0]?.fail();
|
peers[0]?.fail();
|
||||||
assert.equal(media.state().status, "reconnecting");
|
assert.equal(media.state().status, "reconnecting");
|
||||||
assert.equal(peers[0]?.closed, 1);
|
assert.equal(peers[0]?.closed, 1);
|
||||||
@@ -221,6 +269,8 @@ describe("remote office media presenter", () => {
|
|||||||
await settle();
|
await settle();
|
||||||
assert.equal(peers.length, 2);
|
assert.equal(peers.length, 2);
|
||||||
assert.equal(peers[1]?.added[0], track as unknown as MediaStreamTrack);
|
assert.equal(peers[1]?.added[0], track as unknown as MediaStreamTrack);
|
||||||
|
assert.deepEqual(iceTokens, ["private-presenter-grant", "rotated-reconnect-2"],
|
||||||
|
"rebuilt peer refreshes TURN under the current rotated grant");
|
||||||
await media.dispose();
|
await media.dispose();
|
||||||
assert.equal(track.stopped, 0);
|
assert.equal(track.stopped, 0);
|
||||||
});
|
});
|
||||||
@@ -230,6 +280,8 @@ describe("remote office media presenter", () => {
|
|||||||
let joinRequestId = "";
|
let joinRequestId = "";
|
||||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||||
|
const ice = iceResponse(input, body);
|
||||||
|
if (ice) return ice;
|
||||||
if (String(input).endsWith("/join")) {
|
if (String(input).endsWith("/join")) {
|
||||||
joinRequestId = String(body.requestId);
|
joinRequestId = String(body.requestId);
|
||||||
return new Promise<Response>((resolve) => { resolveJoin = resolve; });
|
return new Promise<Response>((resolve) => { resolveJoin = resolve; });
|
||||||
@@ -257,9 +309,15 @@ describe("remote office media presenter", () => {
|
|||||||
it("renews its lease before expiry and uses only the rotated in-memory grant", async () => {
|
it("renews its lease before expiry and uses only the rotated in-memory grant", async () => {
|
||||||
const timers = new FakeTimers();
|
const timers = new FakeTimers();
|
||||||
const eventTokens: string[] = [];
|
const eventTokens: string[] = [];
|
||||||
|
const iceTokens: string[] = [];
|
||||||
let eventCalls = 0;
|
let eventCalls = 0;
|
||||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||||
|
const ice = iceResponse(input, body);
|
||||||
|
if (ice) {
|
||||||
|
iceTokens.push((body.credential as { grantToken: string }).grantToken);
|
||||||
|
return ice;
|
||||||
|
}
|
||||||
if (String(input).endsWith("/join")) {
|
if (String(input).endsWith("/join")) {
|
||||||
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", []), { status: 201 });
|
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", []), { status: 201 });
|
||||||
}
|
}
|
||||||
@@ -284,13 +342,49 @@ describe("remote office media presenter", () => {
|
|||||||
});
|
});
|
||||||
await settle();
|
await settle();
|
||||||
assert.deepEqual(eventTokens, ["private-presenter-grant"]);
|
assert.deepEqual(eventTokens, ["private-presenter-grant"]);
|
||||||
|
assert.deepEqual(iceTokens, ["private-presenter-grant"], "initial resume reuses active TURN configuration");
|
||||||
assert.equal(timers.pending.size, 1, "one pre-expiry renewal is scheduled");
|
assert.equal(timers.pending.size, 1, "one pre-expiry renewal is scheduled");
|
||||||
timers.run();
|
timers.run();
|
||||||
await settle();
|
await settle();
|
||||||
assert.deepEqual(eventTokens, ["private-presenter-grant", "rotated-1"]);
|
assert.deepEqual(eventTokens, ["private-presenter-grant", "rotated-1"]);
|
||||||
|
assert.deepEqual(iceTokens, ["private-presenter-grant"], "lease renewal does not issue unused TURN credentials");
|
||||||
assert.equal(JSON.stringify(media.state()).includes("rotated-2"), false);
|
assert.equal(JSON.stringify(media.state()).includes("rotated-2"), false);
|
||||||
await media.dispose();
|
await media.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates no peer before grant-bound ICE succeeds and cleans up a failed hosted join", async () => {
|
||||||
|
let peerCount = 0;
|
||||||
|
const urls: string[] = [];
|
||||||
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||||
|
const url = String(input);
|
||||||
|
urls.push(url);
|
||||||
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||||
|
if (url.endsWith("/join")) {
|
||||||
|
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", [
|
||||||
|
{ participantId: "viewer-opaque", role: "viewer" },
|
||||||
|
]), { status: 201 });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/ice")) return Response.json({
|
||||||
|
type: "ice-config-unavailable", protocolVersion: 1,
|
||||||
|
requestId: body.requestId, retryAfterMs: 10_000,
|
||||||
|
}, { status: 503 });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
};
|
||||||
|
const track = new FakeTrack();
|
||||||
|
const media = createRemoteOfficeMedia({
|
||||||
|
role: "presenter", binding: BINDING, fetch: fetcher, now: () => NOW,
|
||||||
|
peerConnectionFactory: () => { peerCount += 1; return new FakePeer() as unknown as RTCPeerConnection; },
|
||||||
|
});
|
||||||
|
await assert.rejects(media.startPresenter({
|
||||||
|
consent: { authorized: true, optedIn: true },
|
||||||
|
stream: new FakeStream([track]) as unknown as MediaStream,
|
||||||
|
video: new FakeVideo() as unknown as HTMLVideoElement,
|
||||||
|
}), /temporarily unavailable/);
|
||||||
|
assert.equal(peerCount, 0);
|
||||||
|
assert.deepEqual(urls.map((url) => url.split("/").at(-1)), ["join", "ice", "leave"]);
|
||||||
|
assert.equal(track.stopped, 0);
|
||||||
|
assert.equal(media.state().status, "idle");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("remote office media viewer", () => {
|
describe("remote office media viewer", () => {
|
||||||
@@ -301,6 +395,8 @@ describe("remote office media viewer", () => {
|
|||||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||||
calls.push({ url: String(input), body });
|
calls.push({ url: String(input), body });
|
||||||
|
const iceConfig = iceResponse(input, body);
|
||||||
|
if (iceConfig) return iceConfig;
|
||||||
if (String(input).endsWith("/join")) return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 });
|
if (String(input).endsWith("/join")) return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 });
|
||||||
if (String(input).endsWith("/events")) {
|
if (String(input).endsWith("/events")) {
|
||||||
const resume = grant("screen-share-resume-grant", String(body.requestId), "viewer", [presenterPeer]);
|
const resume = grant("screen-share-resume-grant", String(body.requestId), "viewer", [presenterPeer]);
|
||||||
@@ -348,6 +444,8 @@ describe("remote office media viewer", () => {
|
|||||||
let leaveCalls = 0;
|
let leaveCalls = 0;
|
||||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||||
|
const ice = iceResponse(input, body);
|
||||||
|
if (ice) return ice;
|
||||||
if (String(input).endsWith("/join")) {
|
if (String(input).endsWith("/join")) {
|
||||||
return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 });
|
return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user