diff --git a/deploy/coturn/README.md b/deploy/coturn/README.md index db86293..8b4a6ec 100644 --- a/deploy/coturn/README.md +++ b/deploy/coturn/README.md @@ -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 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: -- username: `:` +- username: `:` - credential: Base64 HMAC-SHA1 of that username using `TERA_TURN_SHARED_SECRET` - response: `Cache-Control: private, no-store` -The shared secret never leaves the server. Expiry prevents new allocations and -refreshes; it cannot instantly terminate an allocation that already exists. +The shared secret never leaves the server. Expiry prevents a credential from +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` 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; - two peers on different networks can exchange a screen track; - 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 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; - no CSP or Permissions Policy violation appears on either Tera entry host. diff --git a/deploy/coturn/turnserver.conf.example b/deploy/coturn/turnserver.conf.example index 8c5c860..41048e8 100644 --- a/deploy/coturn/turnserver.conf.example +++ b/deploy/coturn/turnserver.conf.example @@ -28,10 +28,14 @@ pkey=/etc/coturn/certs/turn.privkey.pem min-port=52000 max-port=53023 -# One credential may briefly own several allocations during ICE restart. The -# total stays below the 1,024-port relay range; tune only from observed usage. +# Coturn keys user-quota by the REST username suffix after the expiry separator. +# 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 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 bps-capacity=500000000 diff --git a/server/src/media/ice.ts b/server/src/media/ice.ts index ab36e90..aab1ac0 100644 --- a/server/src/media/ice.ts +++ b/server/src/media/ice.ts @@ -1,4 +1,4 @@ -import { createHmac, randomBytes } from "node:crypto"; +import { createHmac } from "node:crypto"; import { ICE_CONFIG_PROTOCOL_VERSION, type IceConfigGrant, @@ -13,13 +13,19 @@ export type IceCredentialResult = | { ok: true; value: IceConfigGrant } | { 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 { - issue(request: IceConfigRequest, rateKey: string): IceCredentialResult; + issue(request: IceConfigRequest, rateIdentity: IceCredentialRateIdentity, authorizationExpiresAtMs: number): IceCredentialResult; } export interface IceCredentialProviderOptions { now?: () => number; - nonce?: () => string; } interface RateEntry { startedAtMs: number; count: number } @@ -30,37 +36,39 @@ export function createIceCredentialProvider( options: IceCredentialProviderOptions = {}, ): IceCredentialProvider { const now = options.now ?? Date.now; - const nonce = options.nonce ?? (() => randomBytes(18).toString("base64url")); - const rates = new Map(); + const subjectRates = new Map(); + const ipRates = new Map(); const windowMs = config.rateWindowSeconds * 1_000; return { - issue(request, rateKey) { + issue(request, rateIdentity, authorizationExpiresAtMs) { const at = now(); if (!config.configured) return unavailable(request.requestId, windowMs, "unavailable"); - let entry = rates.get(rateKey); - if (!entry && rates.size >= MAX_RATE_KEYS) { - for (const [key, value] of rates) { - if (at - value.startedAtMs >= windowMs) rates.delete(key); - } - if (rates.size >= MAX_RATE_KEYS) { - 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"); - } - } + // Derive immediately and retain only keyed digests in the bounded maps. + const rateKeys = deriveIceCredentialRateKeys(config.sharedSecret, rateIdentity); + const subjectRetry = rate(subjectRates, rateKeys.subject, at); + const ipRetry = rate(ipRates, rateKeys.ip, at); + const retryAfterMs = Math.max(subjectRetry ?? 0, ipRetry ?? 0); + if (retryAfterMs > 0) return unavailable(request.requestId, retryAfterMs, "rate-limited"); - const expiresAtSeconds = Math.floor(at / 1_000) + config.credentialTtlSeconds; - // Coturn REST convention: expiration timestamp, colon, opaque username. - // No auth subject, email, profile id, IP address, or stable browser id. - const username = `${expiresAtSeconds}:${nonce()}`; + const expiresAtSeconds = Math.min( + Math.floor(at / 1_000) + config.credentialTtlSeconds, + Math.floor(authorizationExpiresAtMs / 1_000), + ); + 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 stunUrls = config.urls.filter((url) => url.startsWith("stun:") || url.startsWith("stuns:")); const turnUrls = config.urls.filter((url) => url.startsWith("turn:") || url.startsWith("turns:")); @@ -80,6 +88,38 @@ export function createIceCredentialProvider( }; }, }; + + function rate(entries: Map, 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( diff --git a/server/src/media/index.ts b/server/src/media/index.ts index a00b8cd..0b58ef4 100644 --- a/server/src/media/index.ts +++ b/server/src/media/index.ts @@ -8,7 +8,9 @@ export { export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts"; export { createIceCredentialProvider, + deriveIceCredentialRateKeys, type IceCredentialProvider, type IceCredentialProviderOptions, + type IceCredentialRateIdentity, type IceCredentialResult, } from "./ice.ts"; diff --git a/server/src/media/service.ts b/server/src/media/service.ts index 0930348..d502dcc 100644 --- a/server/src/media/service.ts +++ b/server/src/media/service.ts @@ -46,6 +46,7 @@ export interface MediaSignalService { signal(request: ScreenShareSignalRequest, subject: string): MediaSignalResult; leave(request: ScreenShareStopRequest | ScreenShareRevokeRequest, subject: string, admin: boolean): MediaSignalResult; 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; revokeSubject(subject: string): number; cleanup(): number; @@ -373,6 +374,19 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {} 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 { let removed = 0; for (const session of [...sessions.values()]) { @@ -409,7 +423,7 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {} 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"); } diff --git a/server/src/routes/media.ts b/server/src/routes/media.ts index 4af1c6d..4712a14 100644 --- a/server/src/routes/media.ts +++ b/server/src/routes/media.ts @@ -46,12 +46,26 @@ function parse(body: unknown) { export function registerMedia(app: FastifyInstance, services: Services): void { app.post<{ Body: unknown }>("/api/v1/media/ice", { bodyLimit: 1_024 }, async (req, reply) => { 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); } const parsed = parseIceConfigRequest(req.body); 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) { const statusCode = issued.code === "rate-limited" ? 429 : 503; return reply.code(statusCode) diff --git a/server/src/test/ice.test.ts b/server/src/test/ice.test.ts index 1f6725f..4d25e86 100644 --- a/server/src/test/ice.test.ts +++ b/server/src/test/ice.test.ts @@ -1,11 +1,13 @@ 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 { buildApp } from "../app.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 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 JWT_SECRET = "ice-route-jwt-secret-that-is-long-enough"; @@ -20,7 +22,16 @@ const configured = (overrides: Partial = {}): IceConfig => ({ ...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) { 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", () => { - 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(), { 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); if (!result.ok) return; const turn = result.value.iceServers.find((server) => "username" in server); assert.ok(turn && "username" in turn); 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.username.includes("auth-subject"), 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", () => { 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); if (!closed.ok) assert.equal(closed.code, "unavailable"); 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); - const second = limited.issue(request, "same-ip"); + assert.equal(limited.issue(request, rateKeys(), 100_000).ok, true); + const second = limited.issue(request, rateKeys(), 100_000); assert.equal(second.ok, false); 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", () => { let time = 10_000; const provider = createIceCredentialProvider(configured({ rateAttempts: 2 }), { - now: () => time, nonce: () => "opaque_random_nonce_1234", + now: () => time, }); 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); if (!full.ok) assert.equal(full.code, "rate-limited"); 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", () => { @@ -97,7 +157,7 @@ describe("coturn REST ICE credential provider", () => { }); 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({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_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()); 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); + 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({ - method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"), - payload: { ...request, profile: "do-not-send" }, + method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter-subject"), + payload: { ...iceRequest(presenter.grant.credential), profile: "do-not-send" }, }); assert.equal(malformed.statusCode, 400); 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.headers["cache-control"], "private, no-store"); @@ -120,17 +188,77 @@ describe("authenticated ICE route", () => { assert.equal(response.headers.location, undefined); const body = response.json(); 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; + 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 () => { const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET }); config.logLevel = "silent"; 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({ - 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(JSON.stringify(response.json()).includes("credential"), false); }); diff --git a/server/src/test/media.test.ts b/server/src/test/media.test.ts index 0dffca8..e479d20 100644 --- a/server/src/test/media.test.ts +++ b/server/src/test/media.test.ts @@ -76,6 +76,23 @@ describe("office media signaling service", () => { 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", () => { const service = createMediaSignalService(); const { presenter, viewer } = grants(service); diff --git a/src/main.ts b/src/main.ts index 4c087b2..c79c57a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2365,17 +2365,10 @@ async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise remotePanelStatus(surface.screenId, "connecting"); showDetail(`Connecting to ${surface.screenId}…`); try { - const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([ - import("./media/remoteMedia.ts"), - import("./media/iceClient.ts"), - ]); + const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts"); const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false; if (operation !== remoteMediaOperation || access.subject === null || !inside || !office || 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"); video.muted = true; video.playsInline = true; @@ -2388,7 +2381,6 @@ async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise role: "viewer", binding: screenBinding(surface), authenticatedFetch: authFetch, - peerConnectionConfiguration: ice.configuration, onStateChange: (state) => { if (viewing) syncRemoteViewerState(viewing, state); }, onError: (error) => { if (remoteViewedScreen !== viewing) return; @@ -2423,18 +2415,12 @@ async function startRemotePresenter( if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return; remotePanelStatus(surface.screenId, "connecting"); try { - const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([ - 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 }); + const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts"); if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return; const remote = createRemoteOfficeMedia({ role: "presenter", binding: screenBinding(surface), authenticatedFetch: authFetch, - peerConnectionConfiguration: ice.configuration, onStateChange(state) { if (sharedScreen !== shared) return; if (state.status === "live") remotePanelStatus(surface.screenId, "live"); diff --git a/src/media/iceClient.ts b/src/media/iceClient.ts index 07615d8..50e9fc6 100644 --- a/src/media/iceClient.ts +++ b/src/media/iceClient.ts @@ -1,5 +1,6 @@ import { ICE_CONFIG_PROTOCOL_VERSION } from "./iceTypes.ts"; import { isIceConfigGrantActive, parseIceConfigResponse } from "./iceValidation.ts"; +import type { ScreenShareBinding, ScreenShareCredential } from "./signalingTypes.ts"; export interface EphemeralIceConfiguration { readonly configuration: RTCConfiguration; @@ -8,6 +9,9 @@ export interface EphemeralIceConfiguration { export interface FetchEphemeralIceConfigurationOptions { authenticatedFetch: typeof globalThis.fetch; + /** Exact server-authored screen and active signaling capability. */ + binding: ScreenShareBinding; + credential: ScreenShareCredential; endpoint?: string; now?: () => number; requestId?: () => string; @@ -49,6 +53,8 @@ export async function fetchEphemeralIceConfiguration( type: "ice-config-request", protocolVersion: ICE_CONFIG_PROTOCOL_VERSION, requestId: id, + binding: { ...options.binding }, + credential: { ...options.credential }, }), }); let body: unknown; diff --git a/src/media/iceTypes.ts b/src/media/iceTypes.ts index 0c2e159..0445dc3 100644 --- a/src/media/iceTypes.ts +++ b/src/media/iceTypes.ts @@ -1,11 +1,15 @@ /** 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 interface IceConfigRequest { type: "ice-config-request"; protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION; requestId: string; + binding: ScreenShareBinding; + credential: ScreenShareCredential; } export interface StunIceServer { diff --git a/src/media/iceValidation.ts b/src/media/iceValidation.ts index 070287b..b53174b 100644 --- a/src/media/iceValidation.ts +++ b/src/media/iceValidation.ts @@ -35,12 +35,27 @@ export function isSafeIceUrl(value: unknown): value is string { } export function parseIceConfigRequest(value: unknown): IceConfigValidationResult { - 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 || - !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); } +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 { if (!plain(value) || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION || !identifier(value.requestId)) { return failure("invalid response envelope"); diff --git a/src/media/remoteMedia.ts b/src/media/remoteMedia.ts index f2f3776..a93663d 100644 --- a/src/media/remoteMedia.ts +++ b/src/media/remoteMedia.ts @@ -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; clearTimeout(handle: ReturnType): 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; endpoints?: Partial; 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 | null = null; let renewalTimer: ReturnType | 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 { + 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 { + 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 { 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"); } diff --git a/src/test/iceClient.test.ts b/src/test/iceClient.test.ts index f058a58..8dc5ef7 100644 --- a/src/test/iceClient.test.ts +++ b/src/test/iceClient.test.ts @@ -5,6 +5,14 @@ import { IceConfigurationUnavailableError, } 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", () => { it("posts the exact authenticated request and returns an in-memory RTC configuration", async () => { let input: RequestInfo | URL | undefined; @@ -32,6 +40,7 @@ describe("ephemeral ICE configuration client", () => { }, now: () => 200, requestId: () => "ice-test-1", + ...authorization, }); assert.equal(input, "/api/v1/media/ice"); assert.equal(init?.method, "POST"); @@ -41,6 +50,7 @@ describe("ephemeral ICE configuration client", () => { type: "ice-config-request", protocolVersion: 1, requestId: "ice-test-1", + ...authorization, }); assert.deepEqual(result.configuration.iceServers, [ { urls: ["stun:relay.example.test:3478"] }, @@ -59,6 +69,7 @@ describe("ephemeral ICE configuration client", () => { authenticatedFetch: async () => Response.json(body), now: () => 200, requestId: () => "ice-test-1", + ...authorization, }); await assert.rejects(run({ type: "ice-config-unavailable", @@ -98,6 +109,7 @@ describe("ephemeral ICE configuration client", () => { retryAfterMs: 12_345, }, { status }), requestId: () => "ice-backoff-1", + ...authorization, }), (error: unknown) => { assert.ok(error instanceof IceConfigurationUnavailableError); assert.equal(error.retryAfterMs, 12_345); @@ -114,11 +126,13 @@ describe("ephemeral ICE configuration client", () => { retryAfterMs: 12_345, }, { status: 503 }), requestId: () => "ice-backoff-1", + ...authorization, }), /did not match/); await assert.rejects(fetchEphemeralIceConfiguration({ authenticatedFetch: async () => Response.json({ retryAfterMs: 1 }, { status: 503 }), requestId: () => "ice-backoff-1", + ...authorization, }), /failed \(503\)/); }); }); diff --git a/src/test/iceConfig.test.ts b/src/test/iceConfig.test.ts index a376b6e..ee41ff9 100644 --- a/src/test/iceConfig.test.ts +++ b/src/test/iceConfig.test.ts @@ -8,6 +8,16 @@ import { type IceConfigGrant, } 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 => ({ type: "ice-config-grant", protocolVersion: 1, @@ -27,9 +37,7 @@ const grant = (): IceConfigGrant => ({ describe("ICE configuration wire contract", () => { it("accepts exact requests and bounded ephemeral grants", () => { - assert.equal(parseIceConfigRequest({ - type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1", - }).ok, true); + assert.equal(parseIceConfigRequest(request()).ok, true); assert.equal(parseIceConfigResponse(JSON.parse(JSON.stringify(grant()))).ok, true); assert.equal(isIceConfigGrantActive(grant(), 300_000), true); 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", () => { - assert.equal(parseIceConfigRequest({ - type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1", subject: "stable-user", - }).ok, false); + assert.equal(parseIceConfigRequest({ ...request(), subject: "stable-user" }).ok, false); + const { credential: _credential, ...withoutCredential } = request(); + 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(), iceServers: [{ urls: ["stun:relay.example.test:3478"] }] }).ok, false); assert.equal(parseIceConfigResponse({ ...grant(), expiresAtMs: 3_601_001 }).ok, false); diff --git a/src/test/remoteMedia.test.ts b/src/test/remoteMedia.test.ts index 4b53107..fa8195d 100644 --- a/src/test/remoteMedia.test.ts +++ b/src/test/remoteMedia.test.ts @@ -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): Response | null { + return String(input).endsWith("/ice") + ? Response.json(iceGrant(String(body.requestId))) + : null; +} + function sse(messages: readonly unknown[]): Response { const bytes = new TextEncoder().encode(messages.map((message) => `data: ${JSON.stringify(message)}\n\n`).join("")); return new Response(new ReadableStream({ @@ -165,6 +187,8 @@ describe("remote office media presenter", () => { const fetcher: typeof fetch = async (input, init = {}) => { const body = JSON.parse(String(init.body)) as Record; calls.push({ url: String(input), init, body }); + const ice = iceResponse(input, body); + if (ice) return ice; if (String(input).endsWith("/join")) { 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.muted, true); 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?.init.credentials, "same-origin"); 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 () => { const timers = new FakeTimers(); const peers: FakePeer[] = []; + const iceTokens: string[] = []; + let eventCalls = 0; const participant = { participantId: "viewer-opaque", role: "viewer" as const }; const fetcher: typeof fetch = async (input, init = {}) => { const body = JSON.parse(String(init.body)) as Record; + 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("/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 }); }; const media = createRemoteOfficeMedia({ @@ -214,6 +260,8 @@ describe("remote office media presenter", () => { }); 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 settle(); + assert.deepEqual(iceTokens, ["private-presenter-grant"], "connected peer keeps its active TURN allocation across token rotation"); peers[0]?.fail(); assert.equal(media.state().status, "reconnecting"); assert.equal(peers[0]?.closed, 1); @@ -221,6 +269,8 @@ describe("remote office media presenter", () => { await settle(); assert.equal(peers.length, 2); 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(); assert.equal(track.stopped, 0); }); @@ -230,6 +280,8 @@ describe("remote office media presenter", () => { let joinRequestId = ""; const fetcher: typeof fetch = async (input, init = {}) => { const body = JSON.parse(String(init.body)) as Record; + const ice = iceResponse(input, body); + if (ice) return ice; if (String(input).endsWith("/join")) { joinRequestId = String(body.requestId); return new Promise((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 () => { const timers = new FakeTimers(); const eventTokens: string[] = []; + const iceTokens: string[] = []; let eventCalls = 0; const fetcher: typeof fetch = async (input, init = {}) => { const body = JSON.parse(String(init.body)) as Record; + 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", []), { status: 201 }); } @@ -284,13 +342,49 @@ describe("remote office media presenter", () => { }); await settle(); 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"); timers.run(); await settle(); 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); 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; + 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", () => { @@ -301,6 +395,8 @@ describe("remote office media viewer", () => { const fetcher: typeof fetch = async (input, init = {}) => { const body = JSON.parse(String(init.body)) as Record; 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("/events")) { const resume = grant("screen-share-resume-grant", String(body.requestId), "viewer", [presenterPeer]); @@ -348,6 +444,8 @@ describe("remote office media viewer", () => { let leaveCalls = 0; const fetcher: typeof fetch = async (input, init = {}) => { const body = JSON.parse(String(init.body)) as Record; + const ice = iceResponse(input, body); + if (ice) return ice; if (String(input).endsWith("/join")) { return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 }); }