security: bind TURN grants to screen sessions
This commit is contained in:
+68
-28
@@ -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<string, RateEntry>();
|
||||
const subjectRates = new Map<string, RateEntry>();
|
||||
const ipRates = new Map<string, RateEntry>();
|
||||
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<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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface MediaSignalService {
|
||||
signal(request: ScreenShareSignalRequest, subject: string): MediaSignalResult<null>;
|
||||
leave(request: ScreenShareStopRequest | ScreenShareRevokeRequest, subject: string, admin: boolean): MediaSignalResult<null>;
|
||||
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>;
|
||||
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"); }
|
||||
|
||||
@@ -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)
|
||||
|
||||
+151
-23
@@ -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> = {}): 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<IceConfigGrant>();
|
||||
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 () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user