1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/server/src/media/ice.ts
T

141 lines
5.3 KiB
TypeScript

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<string, RateEntry>();
const ipRates = new Map<string, RateEntry>();
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<StunIceServer | TurnIceServer> = [];
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<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(
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))),
},
};
}