import { createHmac } from "node:crypto"; import { ICE_CONFIG_PROTOCOL_VERSION, type IceConfigGrant, type IceConfigRequest, type IceConfigUnavailable, type StunIceServer, type TurnIceServer, } from "../../../src/media/iceTypes.ts"; import type { IceConfig } from "../config.ts"; 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, rateIdentity: IceCredentialRateIdentity, authorizationExpiresAtMs: number): IceCredentialResult; } export interface IceCredentialProviderOptions { now?: () => number; } interface RateEntry { startedAtMs: number; count: number } const MAX_RATE_KEYS = 4_096; export function createIceCredentialProvider( config: IceConfig, options: IceCredentialProviderOptions = {}, ): IceCredentialProvider { const now = options.now ?? Date.now; const subjectRates = new Map(); const ipRates = new Map(); const windowMs = config.rateWindowSeconds * 1_000; return { issue(request, rateIdentity, authorizationExpiresAtMs) { const at = now(); if (!config.configured) return unavailable(request.requestId, windowMs, "unavailable"); // 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.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:")); const iceServers: Array = []; if (stunUrls.length > 0) iceServers.push({ urls: stunUrls }); iceServers.push({ urls: turnUrls, username, credential, credentialType: "password" }); return { ok: true, value: { type: "ice-config-grant", protocolVersion: ICE_CONFIG_PROTOCOL_VERSION, requestId: request.requestId, issuedAtMs: at, expiresAtMs: expiresAtSeconds * 1_000, iceServers, }, }; }, }; 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( requestId: string, retryAfterMs: number, code: "unavailable" | "rate-limited", ): IceCredentialResult { return { ok: false, code, value: { type: "ice-config-unavailable", protocolVersion: ICE_CONFIG_PROTOCOL_VERSION, requestId, retryAfterMs: Math.min(3_600_000, Math.max(1_000, Math.ceil(retryAfterMs))), }, }; }