1
0

security: bind TURN grants to screen sessions

This commit is contained in:
2026-08-11 21:48:11 -07:00
parent 5ca214e4bb
commit 19f022f71a
16 changed files with 530 additions and 93 deletions
+68 -28
View File
@@ -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(
+2
View File
@@ -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";
+15 -1
View File
@@ -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"); }