268 lines
15 KiB
TypeScript
268 lines
15 KiB
TypeScript
import assert from "node:assert/strict";
|
|
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, 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";
|
|
|
|
const configured = (overrides: Partial<IceConfig> = {}): IceConfig => ({
|
|
configured: true,
|
|
urls: ["stun:relay.example.test:3478", "turn:relay.example.test:3478", "turns:relay.example.test:5349"],
|
|
sharedSecret: SHARED_SECRET,
|
|
credentialTtlSeconds: 600,
|
|
rateAttempts: 2,
|
|
rateWindowSeconds: 60,
|
|
...overrides,
|
|
});
|
|
|
|
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: { ...binding }, credential: { ...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");
|
|
const signed = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ sub: subject, exp: Math.floor(Date.now() / 1000) + 600 })}`;
|
|
return { authorization: `Bearer ${signed}.${createHmac("sha256", JWT_SECRET).update(signed).digest("base64url")}` };
|
|
}
|
|
|
|
describe("coturn REST ICE credential provider", () => {
|
|
it("uses an opaque quota identity and the standard expiration:HMAC-SHA1 credential", () => {
|
|
const provider = createIceCredentialProvider(configured(), {
|
|
now: () => 1_700_000_000_500,
|
|
});
|
|
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;
|
|
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);
|
|
assert.equal(parseIceConfigResponse(result.value).ok, true);
|
|
});
|
|
|
|
it("fails closed when unconfigured and rate bounds issuance", () => {
|
|
const unavailable = createIceCredentialProvider(configured({ configured: false, sharedSecret: "", urls: [] }));
|
|
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,
|
|
});
|
|
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, 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,
|
|
});
|
|
for (let index = 0; index < 4_096; index += 1) {
|
|
assert.equal(provider.issue(request, rateKeys(`subject-${index}`, `ip-${index}`), 100_000).ok, true);
|
|
}
|
|
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, 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", () => {
|
|
assert.equal(loadConfig({ TERA_ICE_URLS: "turn:relay.example.test:3478" }).ice.configured, false);
|
|
assert.equal(loadConfig({ TERA_TURN_SHARED_SECRET: SHARED_SECRET }).ice.configured, false);
|
|
assert.equal(loadConfig({
|
|
TERA_ICE_URLS: "turn:user@relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET,
|
|
}).ice.configured, false);
|
|
assert.equal(loadConfig({
|
|
TERA_ICE_URLS: "turn:relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET,
|
|
TERA_TURN_CREDENTIAL_TTL: "60.5",
|
|
}).ice.configured, false);
|
|
const valid = loadConfig({
|
|
TERA_ICE_URLS: "stun:relay.example.test:3478, turns:relay.example.test:5349",
|
|
TERA_TURN_SHARED_SECRET: SHARED_SECRET,
|
|
});
|
|
assert.equal(valid.ice.configured, true);
|
|
assert.equal(valid.degraded.some((line) => line.includes(SHARED_SECRET)), false);
|
|
});
|
|
});
|
|
|
|
describe("authenticated ICE route", () => {
|
|
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,
|
|
});
|
|
config.logLevel = "silent";
|
|
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("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("presenter-subject"),
|
|
payload: iceRequest(presenter.grant.credential),
|
|
});
|
|
assert.equal(response.statusCode, 200);
|
|
assert.equal(response.headers["cache-control"], "private, no-store");
|
|
assert.equal(response.headers.pragma, "no-cache");
|
|
assert.equal(response.headers.location, undefined);
|
|
const body = response.json<IceConfigGrant>();
|
|
assert.equal(parseIceConfigResponse(body).ok, true);
|
|
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);
|
|
const stolen = await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("thief"), payload: iceRequest(presenter) });
|
|
assert.equal(stolen.statusCode, 401, stolen.body);
|
|
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: iceRequest(credential),
|
|
});
|
|
assert.equal(response.statusCode, 503, response.body);
|
|
assert.equal(response.json().type, "ice-config-unavailable");
|
|
assert.equal(JSON.stringify(response.json()).includes("credential"), false);
|
|
});
|
|
});
|