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(
|
||||
|
||||
Reference in New Issue
Block a user